Skip to content

Commit

Permalink
sbom dependency tree enhancements
Browse files Browse the repository at this point in the history
  • Loading branch information
avzz-19 committed Mar 3, 2025
1 parent 351b48e commit f723015
Show file tree
Hide file tree
Showing 10 changed files with 413 additions and 79 deletions.
3 changes: 3 additions & 0 deletions app/components/sbom/component-details/overview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default class SbomComponentDetailsOverviewComponent extends Component<Sbo
@service declare intl: IntlService;
@service declare store: Store;
@service declare router: RouterService;
@service('notifications') declare notify: NotificationService;

@tracked expandedNodes: string[] = [];
@tracked treeNodes: AkTreeNodeProps[] = [];
Expand Down Expand Up @@ -158,6 +159,8 @@ export default class SbomComponentDetailsOverviewComponent extends Component<Sbo
const sbProjectId = this.sbomProject?.get('id') || '';
const sbFileId = this.sbomFile?.get('id') || '';

this.notify.error(this.intl.t('sbomModule.parentComponentNotFound'));

this.router.transitionTo(
'authenticated.dashboard.sbom.component-details',
sbProjectId,
Expand Down
12 changes: 9 additions & 3 deletions app/components/sbom/scan-details/component-tree/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
@underline='hover'
data-test-component-tree-nodeLabel
>
{{node.dataObject.bomRef}}&nbsp;:&nbsp;{{node.label}}
{{node.dataObject.purl}}
</AkTypography>
</AkButton>

Expand All @@ -142,14 +142,19 @@
/>

{{#if node.dataObject.isHighlighted}}
<AkStack local-class='highlighted-return-icon-container'>
<AkTooltip
@title={{t 'highlightedNodeTooltip'}}
@placement='right'
@arrow={{true}}
local-class='highlighted-return-icon-container'
>
<AkIcon
data-test-component-tree-returnIcon
@iconName='keyboard-return'
@size='small'
local-class='highlighted-return-icon'
/>
</AkStack>
</AkTooltip>
{{/if}}
</AkStack>

Expand All @@ -166,6 +171,7 @@
'click'
(fn this.handleLoadMoreChildren node.parent.key)
}}
data-test-component-tree-child-viewMore
>
<:default>{{t 'viewMore'}}</:default>
<:rightIcon>
Expand Down
141 changes: 85 additions & 56 deletions app/components/sbom/scan-details/component-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,27 +361,41 @@ export default class SbomScanDetailsComponentTreeComponent extends Component<Sbo
@action
transformApiDataToTreeFormat(
components: SbomComponentModel[],
parentId?: string
parentKey?: string
) {
return components.map((item: SbomComponentModel, index) => ({
// For children, create composite key. For parents, use just the ID
key: parentId ? `${parentId}:${item.id}` : item.id.toString(),
label: item.name,
dataObject: {
name: item.name,
bomRef: item.bomRef.substring(0, item.bomRef.lastIndexOf(':')),
version: item.version,
latestVersion: item.latestVersion,
vulnerabilitiesCount: item.vulnerabilitiesCount,
hasChildren: item.dependencyCount > 0,
dependencyCount: item.dependencyCount,
hasNextSibling: index < components.length - 1,
isDependency: parentId ? true : false,
originalComponent: item,
isHighlighted: false,
},
children: [] as AkTreeNodeProps[],
}));
return components.map((item: SbomComponentModel, index) => {
// Extract parts from bomRef
const bomRefParts = item.bomRef.split(':').filter(Boolean);
const ecosystem = bomRefParts[0] || 'generic';
const group = bomRefParts.length === 3 ? bomRefParts[1] : ''; // Present only in 3-part bomRef

// Construct PURL
const groupPrefix = group ? `${group}/` : '';
const versionSuffix = item.version ? `@${item.version}` : '';
const purl = `pkg:${ecosystem}/${groupPrefix}${item.name}${versionSuffix}`;

return {
// For root components, use just the ID
// For children, chain the new ID to the parent's full path
key: parentKey ? `${parentKey}:${item.id}` : item.id.toString(),
label: item.name,
dataObject: {
name: item.name,
bomRef: item.bomRef,
version: item.version,
latestVersion: item.latestVersion,
vulnerabilitiesCount: item.vulnerabilitiesCount,
hasChildren: item.dependencyCount > 0,
dependencyCount: item.dependencyCount,
hasNextSibling: index < components.length - 1,
isDependency: !!parentKey,
originalComponent: item,
isHighlighted: false,
purl,
},
children: [] as AkTreeNodeProps[],
};
});
}

handleNodeExpand = task(async (newExpandedKeys: string[]) => {
Expand All @@ -400,13 +414,10 @@ export default class SbomScanDetailsComponentTreeComponent extends Component<Sbo
if (expandingNode && expandingNode.dataObject.hasChildren) {
// Only load children if we need to
if (this.needsChildrenLoad(addedKey)) {
// Extract the actual component ID from the composite key
const componentId = this.getComponentId(addedKey);

const children = await this.loadChildrenAndTransform.perform(
15,
0,
componentId.toString()
addedKey
);

// Update the node with its children at any level in the tree
Expand Down Expand Up @@ -444,10 +455,18 @@ export default class SbomScanDetailsComponentTreeComponent extends Component<Sbo
// Get component ID directly from the original component
const componentId = node.dataObject.originalComponent.id;

// Get parent ID from the last segment of parentKey if it exists
const parentComponentId = parentKey
? Number(parentKey.split(':').pop())
: 0;
// For the parent ID, we need the immediate parent, not the last in the entire chain
let parentComponentId = 0;

if (parentKey) {
// If node key has a parent structure (meaning it's not a root component),
// extract the immediate parent ID
const nodeKeyParts = node.key.split(':');
if (nodeKeyParts.length > 1) {
// The immediate parent ID is the second-to-last segment in the node's key
parentComponentId = Number(nodeKeyParts[nodeKeyParts.length - 2]);
}
}

this.router.transitionTo(
'authenticated.dashboard.sbom.component-details.overview',
Expand All @@ -473,44 +492,54 @@ export default class SbomScanDetailsComponentTreeComponent extends Component<Sbo
const responseArray = [response];
const nodes = this.transformApiDataToTreeFormat(responseArray);

// Load children
const children = await this.loadChildrenAndTransform.perform(
15,
0,
componentId
);
nodes[0]!.children = children;

// If args.componentId exists and doesn't match the parent's id,
// it must be one of the children we need to highlight
if (this.args.componentId && this.args.componentId !== componentId) {
// Find and highlight the child component
const childToHighlight = children.find(
(child: AkTreeNodeProps) =>
child.dataObject.originalComponent.id === this.args.componentId
if (nodes[0]?.key) {
// Load children using the component's ID as the parent key
const children = await this.loadChildrenAndTransform.perform(
15,
0,
nodes[0].key
);
if (childToHighlight) {
childToHighlight.dataObject.isHighlighted = true;

nodes[0].children = children;

// If args.componentId exists and doesn't match the parent's id,
// it must be one of the children we need to highlight
if (this.args.componentId && this.args.componentId !== componentId) {
// Find and highlight the child component
const childToHighlight = children.find(
(child: AkTreeNodeProps) =>
child.dataObject.originalComponent.id === this.args.componentId
);
if (childToHighlight) {
childToHighlight.dataObject.isHighlighted = true;
}
} else {
// Highlight the parent node if it matches componentId
nodes[0].dataObject.isHighlighted = true;
}
} else {
// Highlight the parent node if it matches componentId
nodes[0]!.dataObject.isHighlighted = true;
nodes[0]!.children = [];
}

this.handleNodeExpand.perform([componentId]);
this.args.updateExpandedNodes([componentId]);
if (nodes[0]?.key) {
this.handleNodeExpand.perform([nodes[0].key]);
this.args.updateExpandedNodes([nodes[0].key]);
}
this.args.updateTreeNodes(nodes);
});

loadChildrenAndTransform = task(
async (limit: number, offset: number, parentId: string) => {
async (limit: number, offset: number, parentKey: string) => {
// Add to loading array
this.loadingChildrenKeys = [...this.loadingChildrenKeys, parentId];
this.loadingChildrenKeys = [...this.loadingChildrenKeys, parentKey];

// Extract the component ID from the parent key
const componentId = this.getComponentId(parentKey);

const queryParams = {
type: 1,
sbomFileId: this.args.sbomFile.id,
componentId: parentId,
componentId: componentId,
limit,
offset,
};
Expand All @@ -520,21 +549,21 @@ export default class SbomScanDetailsComponentTreeComponent extends Component<Sbo
this.store.query('sbom-component', queryParams)
)) as SbomComponentQueryResponse;

// Pass parentId to create composite keys for children
// Pass the full parent key to create properly chained composite keys for children
const transformedChildren = this.transformApiDataToTreeFormat(
response.slice(),
parentId
parentKey
);

// Return transformed children without updating the tree directly
this.loadingChildrenKeys = this.loadingChildrenKeys.filter(
(key) => key !== parentId
(key) => key !== parentKey
);

return transformedChildren;
} catch (error) {
this.loadingChildrenKeys = this.loadingChildrenKeys.filter(
(key) => key !== parentId
(key) => key !== parentKey
);

return [];
Expand Down
24 changes: 10 additions & 14 deletions app/components/sbom/scan-details/skeleton-loader-list/index.hbs
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
<AkStack
@alignItems='flex-start'
@direction='column'
{{style alignSelf='flex-start'}}
>
{{#each (array 0 1 2 3 4 5 6 7 8 9 10 11 12 13)}}
<AkSkeleton
class='mt-2 ml-2'
@width='600px'
@height='19px'
data-test-component-list-skeleton-loader
/>
{{/each}}
</AkStack>
<AkTable data-test-component-list-skeleton-loader as |t|>
<t.head @columns={{this.columns}} />
<t.body @rows={{this.loadingMockData}} as |b|>
<b.row as |r|>
<r.cell>
<AkSkeleton @width='100px' @height='20px' />
</r.cell>
</b.row>
</t.body>
</AkTable>
29 changes: 28 additions & 1 deletion app/components/sbom/scan-details/skeleton-loader-list/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import { service } from '@ember/service';
import Component from '@glimmer/component';

export default class SbomScanDetailsSkeletonLoaderListComponent extends Component {}
import type IntlService from 'ember-intl/services/intl';

export default class SbomScanDetailsSkeletonLoaderListComponent extends Component {
@service declare intl: IntlService;

get columns() {
return [
{
name: this.intl.t('sbomModule.componentName'),
width: 150,
},
{
name: this.intl.t('sbomModule.componentType'),
},
{
name: this.intl.t('dependencyType'),
},
{
name: this.intl.t('status'),
},
];
}

get loadingMockData() {
return new Array(10).fill({});
}
}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
Expand Down
7 changes: 4 additions & 3 deletions mirage/factories/sbom-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ export default Factory.extend({
// Reference and ecosystem info
bom_ref() {
const namespace = faker.helpers.arrayElement([
'pkg',
'maven',
'npm',
'pypi',
'nuget',
]);
const group = faker.string.alphanumeric(10).toLowerCase();
const name = faker.string.alphanumeric(10).toLowerCase();
const version = faker.system.semver();
return `${namespace}:${name}@${version}`;

return `${namespace}::${group}:${name}`;
},

properties() {
Expand Down
Loading

0 comments on commit f723015

Please sign in to comment.