Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sbom dependency tree enhancements #1539

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading