Skip to content

Commit eb65130

Browse files
refector to tooltips
Signed-off-by: Valentijn Scholten <valentijnscholten@gmail.com>
1 parent 307bd1b commit eb65130

File tree

11 files changed

+306
-5
lines changed

11 files changed

+306
-5
lines changed

src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,23 @@
2727
import org.dependencytrack.model.Component;
2828
import org.dependencytrack.model.Finding;
2929
import org.dependencytrack.model.GroupedFinding;
30+
import org.dependencytrack.model.Project;
3031
import org.dependencytrack.model.RepositoryMetaComponent;
3132
import org.dependencytrack.model.RepositoryType;
3233
import org.dependencytrack.model.Vulnerability;
3334
import org.dependencytrack.model.VulnerabilityAlias;
3435

3536
import javax.jdo.PersistenceManager;
3637
import javax.jdo.Query;
38+
import org.dependencytrack.resources.v1.vo.AffectedProject;
39+
3740
import java.util.ArrayList;
3841
import java.util.HashMap;
3942
import java.util.List;
4043
import java.util.Map;
44+
import java.util.Set;
4145
import java.util.UUID;
46+
import java.util.stream.Collectors;
4247

4348
public class FindingsSearchQueryManager extends QueryManager implements IQueryManager {
4449

@@ -157,10 +162,37 @@ public PaginatedResult getAllFindings(final Map<String, String> filters, final b
157162
}
158163
findings.add(finding);
159164
}
165+
populateParentChains(findings);
160166
result.setObjects(findings);
161167
return result;
162168
}
163169

170+
/**
171+
* Populates the parent chain for each finding's project so the UI can render tooltips
172+
* (e.g. "Root > Parent > Project" for nested project hierarchies).
173+
*/
174+
private void populateParentChains(List<Finding> findings) {
175+
final Set<UUID> projectUuids = findings.stream()
176+
.map(f -> (String) f.getComponent().get("project"))
177+
.filter(s -> s != null && !s.isBlank())
178+
.map(UUID::fromString)
179+
.collect(Collectors.toSet());
180+
if (projectUuids.isEmpty()) {
181+
return;
182+
}
183+
final Map<UUID, Project> projectMap = getProjectsWithAncestorPaths(projectUuids);
184+
for (final Finding finding : findings) {
185+
final String projectUuidStr = (String) finding.getComponent().get("project");
186+
if (projectUuidStr == null) {
187+
continue;
188+
}
189+
final Project project = projectMap.get(UUID.fromString(projectUuidStr));
190+
if (project != null) {
191+
finding.getComponent().put("parent", AffectedProject.buildParentInfo(project.getParent()));
192+
}
193+
}
194+
}
195+
164196
/**
165197
* Returns a List of all Finding objects filtered by ACL and other optional filters. The resulting list is grouped by vulnerability.
166198
* @param filters determines the filters to apply on the list of Finding objects

src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,11 @@ public PaginatedResult getPolicyViolations(final Project project, boolean includ
419419
violation.getComponent().getResolvedLicense(); // force resolved license to ne included since its not the default
420420
violation.setAnalysis(getViolationAnalysis(violation.getComponent(), violation)); // Include the violation analysis by default
421421
}
422+
populateAncestorPaths(result.getList(PolicyViolation.class).stream()
423+
.map(PolicyViolation::getProject)
424+
.filter(Objects::nonNull)
425+
.distinct()
426+
.toList());
422427
return result;
423428
}
424429

@@ -444,6 +449,11 @@ public PaginatedResult getPolicyViolations(final Component component, boolean in
444449
violation.getComponent().getResolvedLicense(); // force resolved license to ne included since its not the default
445450
violation.setAnalysis(getViolationAnalysis(violation.getComponent(), violation)); // Include the violation analysis by default
446451
}
452+
populateAncestorPaths(result.getList(PolicyViolation.class).stream()
453+
.map(PolicyViolation::getProject)
454+
.filter(Objects::nonNull)
455+
.distinct()
456+
.toList());
447457
return result;
448458
}
449459

@@ -475,6 +485,11 @@ public PaginatedResult getPolicyViolations(boolean includeSuppressed, boolean sh
475485
violation.getComponent().getResolvedLicense(); // force resolved license to be included since it's not the default
476486
violation.setAnalysis(getViolationAnalysis(violation.getComponent(), violation)); // Include the violation analysis by default
477487
}
488+
populateAncestorPaths(result.getList(PolicyViolation.class).stream()
489+
.map(PolicyViolation::getProject)
490+
.filter(Objects::nonNull)
491+
.distinct()
492+
.toList());
478493
return result;
479494
}
480495

src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,6 +1642,7 @@ public PaginatedResult getChildrenProjects(final UUID uuid, final boolean includ
16421642
preprocessACLs(query, queryFilter, params, false);
16431643
query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
16441644
result = execute(query, params);
1645+
populateAncestorPaths(result.getList(Project.class));
16451646
if (includeMetrics) {
16461647
// Populate each Project object in the paginated result with transitive related
16471648
// data to minimize the number of round trips a client needs to make, process, and render.
@@ -1671,6 +1672,7 @@ public PaginatedResult getChildrenProjects(final Classifier classifier, final UU
16711672
preprocessACLs(query, queryFilter, params, false);
16721673
query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
16731674
result = execute(query, params);
1675+
populateAncestorPaths(result.getList(Project.class));
16741676
if (includeMetrics) {
16751677
// Populate each Project object in the paginated result with transitive related
16761678
// data to minimize the number of round trips a client needs to make, process, and render.
@@ -1703,7 +1705,9 @@ public PaginatedResult getChildrenProjects(final Tag tag, final UUID uuid, final
17031705
final Map<String, Object> params = filterBuilder.getParams();
17041706

17051707
preprocessACLs(query, queryFilter, params, false);
1708+
query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
17061709
result = execute(query, params);
1710+
populateAncestorPaths(result.getList(Project.class));
17071711
if (includeMetrics) {
17081712
// Populate each Project object in the paginated result with transitive related
17091713
// data to minimize the number of round trips a client needs to make, process, and render.
@@ -1819,6 +1823,24 @@ public boolean doesProjectExist(final String name, final String version) {
18191823
}
18201824
}
18211825

1826+
/**
1827+
* Fetches projects by their UUIDs and populates their ancestor paths.
1828+
* Returns a map of project UUID to project for efficient lookup.
1829+
*
1830+
* @param uuids project UUIDs to fetch
1831+
* @return map of project UUID to project with ancestor chains wired
1832+
*/
1833+
public Map<UUID, Project> getProjectsWithAncestorPaths(Collection<UUID> uuids) {
1834+
if (uuids == null || uuids.isEmpty()) {
1835+
return Map.of();
1836+
}
1837+
final List<Project> projects = fetchProjectsByUuids(Set.copyOf(uuids));
1838+
populateAncestorPaths(projects);
1839+
return projects.stream()
1840+
.filter(p -> p.getUuid() != null)
1841+
.collect(Collectors.toMap(Project::getUuid, p -> p, (a, b) -> a));
1842+
}
1843+
18221844
private static boolean isChildOf(Project project, UUID uuid) {
18231845
boolean isChild = false;
18241846
if (project.getParent() != null){

src/main/java/org/dependencytrack/persistence/QueryManager.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,10 @@ public boolean doesProjectExist(final String name, final String version) {
447447
return getProjectQueryManager().doesProjectExist(name, version);
448448
}
449449

450+
public Map<UUID, Project> getProjectsWithAncestorPaths(final Collection<UUID> uuids) {
451+
return getProjectQueryManager().getProjectsWithAncestorPaths(uuids);
452+
}
453+
450454
public Tag getTagByName(final String name) {
451455
return getTagQueryManager().getTagByName(name);
452456
}

src/main/java/org/dependencytrack/resources/v1/PolicyViolationResource.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ public Response getViolationsByComponent(@Parameter(description = "The UUID of t
208208
* <p>
209209
* This ensures that responses include not only the violations themselves, but also the associated
210210
* {@link org.dependencytrack.model.Policy}, which is required to tell the policy name and violation state.
211+
* <p>
212+
* <b>Parent path limitation:</b> Only the direct parent (one level) is included in the project. A full
213+
* ancestor chain is difficult to support here because: (1) Higher JDO fetch depth triggers cycles
214+
* (Project↔children, Component↔project) and causes "Input is too deeply nested" serialization errors;
215+
* (2) This API returns raw JDO entities—a proper full chain would require a response VO with
216+
* {@link org.dependencytrack.resources.v1.vo.ProjectParentInfo} (like {@code AffectedProject}), i.e. an API contract change.
211217
*
212218
* @param qm The {@link QueryManager} to use
213219
* @param violations The {@link PolicyViolation}s to detach
@@ -216,7 +222,7 @@ public Response getViolationsByComponent(@Parameter(description = "The UUID of t
216222
*/
217223
private Collection<PolicyViolation> detachViolations(final QueryManager qm, final Collection<PolicyViolation> violations) {
218224
final PersistenceManager pm = qm.getPersistenceManager();
219-
pm.getFetchPlan().setMaxFetchDepth(2); // Ensure policy is included
225+
pm.getFetchPlan().setMaxFetchDepth(2); // Policy + project + direct parent only; higher depth triggers cycles
220226
pm.getFetchPlan().setDetachmentOptions(FetchPlan.DETACH_LOAD_FIELDS);
221227
return qm.getPersistenceManager().detachCopyAll(violations);
222228
}

src/main/java/org/dependencytrack/resources/v1/TagResource.java

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.dependencytrack.resources.v1.openapi.PaginatedApi;
4444
import org.dependencytrack.resources.v1.problems.ProblemDetails;
4545
import org.dependencytrack.resources.v1.problems.TagOperationProblemDetails;
46+
import org.dependencytrack.resources.v1.vo.AffectedProject;
4647
import org.dependencytrack.resources.v1.vo.TagListResponseItem;
4748
import org.dependencytrack.resources.v1.vo.TaggedCollectionProjectListResponseItem;
4849
import org.dependencytrack.resources.v1.vo.TaggedNotificationRuleListResponseItem;
@@ -62,9 +63,12 @@
6263
import jakarta.ws.rs.core.MediaType;
6364
import jakarta.ws.rs.core.Response;
6465
import java.util.List;
66+
import java.util.Map;
6567
import java.util.Set;
6668
import java.util.UUID;
6769

70+
import org.dependencytrack.model.Project;
71+
6872
@Path("/v1/tag")
6973
@io.swagger.v3.oas.annotations.tags.Tag(name = "tag")
7074
@SecurityRequirements({
@@ -201,12 +205,24 @@ public Response getTaggedProjects(
201205
// Will likely need a migration to cleanup existing tags for this.
202206

203207
final List<TaggedProjectRow> taggedProjectListRows;
208+
final Map<UUID, Project> projectMap;
204209
try (final var qm = new QueryManager(getAlpineRequest())) {
205210
taggedProjectListRows = qm.getTaggedProjects(tagName);
211+
final var uuids = taggedProjectListRows.stream()
212+
.map(row -> UUID.fromString(row.uuid()))
213+
.toList();
214+
projectMap = qm.getProjectsWithAncestorPaths(uuids);
206215
}
207216

208217
final List<TaggedProjectListResponseItem> tags = taggedProjectListRows.stream()
209-
.map(row -> new TaggedProjectListResponseItem(UUID.fromString(row.uuid()), row.name(), row.version()))
218+
.map(row -> {
219+
final var project = projectMap.get(UUID.fromString(row.uuid()));
220+
final var parent = project != null
221+
? AffectedProject.buildParentInfo(project.getParent())
222+
: null;
223+
return new TaggedProjectListResponseItem(
224+
UUID.fromString(row.uuid()), row.name(), row.version(), parent);
225+
})
210226
.toList();
211227
final long totalCount = taggedProjectListRows.isEmpty() ? 0 : taggedProjectListRows.getFirst().totalCount();
212228
return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build();
@@ -312,12 +328,24 @@ public Response getTaggedCollectionProjects(
312328
// Will likely need a migration to cleanup existing tags for this.
313329

314330
final List<TagQueryManager.TaggedCollectionProjectRow> taggedCollectionProjectListRows;
331+
final Map<UUID, Project> projectMap;
315332
try (final var qm = new QueryManager(getAlpineRequest())) {
316333
taggedCollectionProjectListRows = qm.getTaggedCollectionProjects(tagName);
334+
final var uuids = taggedCollectionProjectListRows.stream()
335+
.map(row -> UUID.fromString(row.uuid()))
336+
.toList();
337+
projectMap = qm.getProjectsWithAncestorPaths(uuids);
317338
}
318339

319340
final List<TaggedCollectionProjectListResponseItem> tags = taggedCollectionProjectListRows.stream()
320-
.map(row -> new TaggedCollectionProjectListResponseItem(UUID.fromString(row.uuid()), row.name(), row.version()))
341+
.map(row -> {
342+
final var project = projectMap.get(UUID.fromString(row.uuid()));
343+
final var parent = project != null
344+
? AffectedProject.buildParentInfo(project.getParent())
345+
: null;
346+
return new TaggedCollectionProjectListResponseItem(
347+
UUID.fromString(row.uuid()), row.name(), row.version(), parent);
348+
})
321349
.toList();
322350
final long totalCount = taggedCollectionProjectListRows.isEmpty() ? 0 : taggedCollectionProjectListRows.getFirst().totalCount();
323351
return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build();

src/main/java/org/dependencytrack/resources/v1/vo/TaggedCollectionProjectListResponseItem.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@
3232
public record TaggedCollectionProjectListResponseItem(
3333
@Schema(description = "UUID of the collection project", requiredMode = REQUIRED) UUID uuid,
3434
@Schema(description = "Name of the collection project", requiredMode = REQUIRED) String name,
35-
@Schema(description = "Version of the collection project") String version) {
35+
@Schema(description = "Version of the collection project") String version,
36+
@Schema(description = "Parent project (nested chain for hierarchy display)") ProjectParentInfo parent) {
3637
}

src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@
3232
public record TaggedProjectListResponseItem(
3333
@Schema(description = "UUID of the project", requiredMode = REQUIRED) UUID uuid,
3434
@Schema(description = "Name of the project", requiredMode = REQUIRED) String name,
35-
@Schema(description = "Version of the project") String version) {
35+
@Schema(description = "Version of the project") String version,
36+
@Schema(description = "Parent project (nested chain for hierarchy display)") ProjectParentInfo parent) {
3637
}

src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,43 @@ void testGetProjectPopulatesNestedParentChain() {
157157
Assertions.assertNull(fetched.getParent().getParent().getParent());
158158
}
159159

160+
@Test
161+
void testGetProjectPopulatesDeepParentChain() {
162+
// 4 levels: root -> level1 -> level2 -> leaf
163+
final Project root = qm.createProject("root", null, "1.0", null, null, null, true, false);
164+
final Project level1 = new Project();
165+
level1.setName("level1");
166+
level1.setVersion("1.0");
167+
level1.setParent(root);
168+
qm.persist(level1);
169+
final Project level2 = new Project();
170+
level2.setName("level2");
171+
level2.setVersion("1.0");
172+
level2.setParent(level1);
173+
qm.persist(level2);
174+
final Project leaf = new Project();
175+
leaf.setName("leaf");
176+
leaf.setVersion("1.0");
177+
leaf.setParent(level2);
178+
qm.persist(leaf);
179+
180+
final Project fetched = qm.getProject(leaf.getUuid().toString());
181+
Assertions.assertNotNull(fetched);
182+
183+
// All ancestors must have name/version populated (validates fetch group fix)
184+
Assertions.assertNotNull(fetched.getParent());
185+
Assertions.assertEquals("level2", fetched.getParent().getName());
186+
Assertions.assertEquals("1.0", fetched.getParent().getVersion());
187+
188+
Assertions.assertNotNull(fetched.getParent().getParent());
189+
Assertions.assertEquals("level1", fetched.getParent().getParent().getName());
190+
Assertions.assertEquals("1.0", fetched.getParent().getParent().getVersion());
191+
192+
Assertions.assertNotNull(fetched.getParent().getParent().getParent());
193+
Assertions.assertEquals("root", fetched.getParent().getParent().getParent().getName());
194+
Assertions.assertEquals("1.0", fetched.getParent().getParent().getParent().getVersion());
195+
196+
Assertions.assertNull(fetched.getParent().getParent().getParent().getParent());
197+
}
198+
160199
}

0 commit comments

Comments
 (0)