Skip to content

Comments

feat: html license report#563

Merged
ruromero merged 2 commits intoguacsec:mainfrom
ruromero:oss-license-report
Feb 9, 2026
Merged

feat: html license report#563
ruromero merged 2 commits intoguacsec:mainfrom
ruromero:oss-license-report

Conversation

@ruromero
Copy link
Collaborator

@ruromero ruromero commented Feb 6, 2026

User description

Follow up on #561 to implement the HTML report only
Fix #560


PR Type

Enhancement


Description

  • Add OSS license HTML report with licenses table and pie chart visualization

  • Implement license data structures and mock data for testing

  • Create new UI components for displaying license information by category

  • Add licenses tab to tabbed layout for provider-specific license data

  • Remove unused sign-up link functionality from dependencies table


Diagram Walkthrough

flowchart LR
  A["License Report Data"] --> B["LicenseReport Interface"]
  B --> C["LicensesChartCard"]
  B --> D["LicensesTable"]
  C --> E["Summary Card"]
  D --> F["TabbedLayout"]
  G["LicenseInfo"] --> D
  G --> H["ConcludedLicenseDetail"]
  G --> I["EvidenceLicensesTable"]
  D --> J["LicensesCountByCategory"]
Loading

File Walkthrough

Relevant files
Tests
2 files
HtmlReportTest.java
Add test for licenses table and pie chart                               
+46/-1   
reportBasic.mock.ts
Add mock license data with multiple packages                         
+283/-2 
Enhancement
10 files
report.ts
Add license report interfaces and types                                   
+32/-5   
DepCompoundTable.tsx
Remove sign-up link conditional rendering logic                   
+147/-175
LicensesTable.tsx
Create new licenses table with filtering and expansion     
+301/-0 
SummaryCard.tsx
Add license summary cards to summary grid layout                 
+41/-9   
LicensesCountByCategory.tsx
Create license category color and label utilities               
+82/-0   
LicensesChartCard.tsx
Create donut chart for license summary visualization         
+58/-0   
EvidenceLicensesTable.tsx
Create table for displaying license evidence details         
+71/-0   
TabbedLayout.tsx
Add licenses tabs for each license provider source             
+14/-1   
ConcludedLicenseDetail.tsx
Create detail card for concluded license information         
+24/-0   
ReportErrorAlert.tsx
Remove sign-up tab filtering from error alerts                     
+2/-2     
Additional files
4 files
main.js +1/-1     
vendor.css +1/-1     
vendor.js +1/-1     
utils.ts +0/-18   

@ruromero ruromero requested a review from soul2zimate February 6, 2026 16:46
@ruromero ruromero marked this pull request as draft February 6, 2026 16:47
@qodo-code-review
Copy link
Contributor

qodo-code-review bot commented Feb 6, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🟡
🎫 #560
🟢 Include the license information in the Analysis Report.
Implement the /license endpoint to generate a report of the licenses defined for the given
purls.
Align implementation with the spec changes in
https://github.com/guacsec/trustify-da-api-spec/issues/95 (and related Jira TC-3557).
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Null/shape handling: The Deps.dev response parsing assumes fields like request.purl,
result.version.licenseDetails, and licenseNode.spdx always exist and are arrays/strings,
which can cause runtime exceptions (e.g., NPE/ClassCastException) instead of graceful
handling for partial or malformed upstream responses.

Referred Code
var infos = new ArrayList<LicenseInfo>();
var request = (ObjectNode) response.get("request");
var purl = request.get("purl").asText();
if (response.has("result")) {

  var result = (ObjectNode) response.get("result");
  if (result.has("version")) {
    var version = result.get("version");
    var licensesNode = (ArrayNode) version.get("licenseDetails");
    licensesNode.forEach(
        licenseNode -> {
          var info = new LicenseInfo();
          var spdx = licenseNode.get("spdx").asText();
          info.identifiers(splitLicenses(spdx));
          info.category(resolveCategory(spdx));
          info.expression(spdx);
          info.name(licenseNode.get("license").asText());
          info.source(DEPS_DEV_SOURCE);
          info.sourceUrl(depsDevHost);
          infos.add(info);
        });


 ... (clipped 1 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status:
Leaky client errors: Error messages returned to clients can include upstream HTTP response bodies/status texts
via prettifyHttpError() and mapException(), which may expose internal details from
dependencies to end-users instead of generic user-facing errors.

Referred Code
public static ErrorMapping mapException(Exception exception) {
  if (exception == null) {
    return ErrorMapping.internalError("Unknown error");
  }

  if (isTimeoutException(exception)) {
    return ErrorMapping.timeout();
  }

  if (exception instanceof HttpOperationFailedException http) {
    return new ErrorMapping(prettifyHttpError(http), http.getStatusCode());
  }

  if (isUnknownHostException(exception)) {
    String hostname = getDetailedErrorMessage(exception);
    return ErrorMapping.internalError("Failed to resolve hostname: " + hostname);
  }

  if (exception instanceof IllegalArgumentException
      || exception instanceof UnexpectedProviderException
      || exception instanceof PackageValidationException) {


 ... (clipped 65 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status:
Missing audit context: The new /v5/licenses flow and Deps.dev license lookups do not clearly add audit log events
including user identity and outcome, and it is not verifiable from the diff whether
existing centralized audit logging covers these actions.

Referred Code
from(direct("getLicensesFromEndpoint"))
  .routeId("getLicensesFromEndpoint")
  .unmarshal().json(LicensesRequest.class)
  .transform(method(requestBuilder, "fromEndpoint"))
  .to(direct("getLicenses"))
  .marshal().json();

from(direct("getLicensesFromSbom"))
  .routeId("getLicensesFromSbom")
  .transform(method(requestBuilder, "fromSbom"))
  .to(direct("getLicenses"));

from(direct("getLicenses"))
    .routeId("getLicenses")
    .process(this::lookupCachedLicenses)
    .choice()
      .when(method(requestBuilder, "isEmpty"))
        .process(this::buildResponseFromLicenseCacheHitsOnly)
    .endChoice()
    .otherwise()
        .to(direct("depsDevSplitRequest"))


 ... (clipped 4 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status:
Logs may leak data: Debug/warn logs record exception class names and messages (and may include upstream
response bodies via error mapping), which could contain sensitive or internal information
depending on provider responses and therefore requires verification/redaction policy
review.

Referred Code
    LOGGER.warn("Fallback triggered but no exception found in exchange");
    return null;
  }

  LOGGER.debugf(
      "Handling exception: %s - message: %s",
      exception.getClass().getName(), exception.getMessage());

  String detailedMessage = extractDetailedMessage(exception);
  Exception unwrapped = unwrapException(exception);

  if (!detailedMessage.isEmpty()) {
    exchange.setProperty("detailedErrorMessage", detailedMessage);
  }

  return unwrapped;
}

private static String extractDetailedMessage(Throwable exception) {
  Throwable current = exception;
  while (current != null) {


 ... (clipped 10 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Missing request validation: The new /v5/licenses endpoint path accepts externally supplied purl lists without visible
validation/limits in the diff (e.g., max list size, purl format), which could enable abuse
(resource exhaustion) unless enforced elsewhere.

Referred Code
from(direct("getLicensesFromEndpoint"))
  .routeId("getLicensesFromEndpoint")
  .unmarshal().json(LicensesRequest.class)
  .transform(method(requestBuilder, "fromEndpoint"))
  .to(direct("getLicenses"))
  .marshal().json();

from(direct("getLicensesFromSbom"))
  .routeId("getLicensesFromSbom")
  .transform(method(requestBuilder, "fromSbom"))
  .to(direct("getLicenses"));

from(direct("getLicenses"))
    .routeId("getLicenses")
    .process(this::lookupCachedLicenses)
    .choice()
      .when(method(requestBuilder, "isEmpty"))
        .process(this::buildResponseFromLicenseCacheHitsOnly)
    .endChoice()
    .otherwise()
        .to(direct("depsDevSplitRequest"))


 ... (clipped 4 lines)

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review
Copy link
Contributor

qodo-code-review bot commented Feb 6, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Correct license summary calculation logic

Refactor buildSummary to calculate license counts based on the concluded license
for each package, preventing inflated totals from multi-license expressions.

src/main/java/io/github/guacsec/trustifyda/integration/licenses/DepsDevResponseHandler.java [285-311]

 private LicensesSummary buildSummary(Map<String, PackageLicenseResult> results) {
-  List<LicenseInfo> allInfos =
+  List<CategoryEnum> categories =
       results.values().stream()
-          .map(PackageLicenseResult::getEvidence)
-          .flatMap(List::stream)
-          .toList();
-  List<CategoryEnum> categories =
-      allInfos.stream()
+          .map(PackageLicenseResult::getConcluded)
           .filter(Objects::nonNull)
-          .flatMap(
-              info ->
-                  (info.getIdentifiers() != null ? info.getIdentifiers() : List.<String>of())
-                      .stream())
-          .map(this::getCategory)
+          .map(LicenseInfo::getCategory)
           .filter(Objects::nonNull)
           .toList();
+
   Map<CategoryEnum, Long> byCategory =
       categories.stream().collect(Collectors.groupingBy(c -> c, Collectors.counting()));
 
   return new LicensesSummary()
       .total(categories.size())
       .concluded(results.size())
       .permissive(byCategory.getOrDefault(CategoryEnum.PERMISSIVE, 0L).intValue())
       .weakCopyleft(byCategory.getOrDefault(CategoryEnum.WEAK_COPYLEFT, 0L).intValue())
       .strongCopyleft(byCategory.getOrDefault(CategoryEnum.STRONG_COPYLEFT, 0L).intValue())
       .unknown(byCategory.getOrDefault(CategoryEnum.UNKNOWN, 0L).intValue());
 }
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: This suggestion correctly identifies a bug in the buildSummary method where the total license count is inflated, and provides a correct implementation that bases the summary on the concluded license for each package.

Medium
Use realistic test data for artifacts

Update the test data in batch_report.json to be realistic for each artifact
type. The current license data for OCI images incorrectly uses Maven package
information; replace it with relevant data or an empty structure.

src/test/resources/__files/reports/batch_report.json [653-1284]

     ...
      "pkg:oci/default-app@sha256:7c288032ecf3319045d9fa538c3b0cc868a320d01d03bce15b99c2c336319994?repository_url=quay.io/default-app&tag=0.0.1": {
          "scanned": {
 ...
          "licenses": [
              {
                  "status": {
-...
+                     "ok": true,
+                     "name": "deps.dev",
+                     "message": "OK",
+                     "warnings": {}
                  },
-                 "packages": {
-                     "pkg:maven/io.quarkus/quarkus-core@2.13.5.Final": {
-...
-                     },
-...
-                 }
+                 "summary": {
+                     "total": 0,
+                     "concluded": 0,
+                     "permissive": 0,
+                     "weak-copyleft": 0,
+                     "strong-copyleft": 0,
+                     "unknown": 0
+                 },
+                 "packages": {}
              }
          ]
      },
      "pkg:oci/debian@sha256:7c288032ecf3319045d9fa538c3b0cc868a320d01d03bce15b99c2c336319994?tag=0.0.1": {
          "scanned": {
 ...
          },
          "licenses": [
              {
                  "status": {
-...
+                     "ok": true,
+                     "name": "deps.dev",
+                     "message": "OK",
+                     "warnings": {}
+                 },
+                 "summary": {
+                     "total": 1,
+                     "concluded": 1,
+                     "permissive": 1,
+                     "weak-copyleft": 0,
+                     "strong-copyleft": 0,
+                     "unknown": 0
                  },
                  "packages": {
-                     "pkg:maven/io.quarkus/quarkus-core@2.13.5.Final": {
-...
-                     },
-...
+                     "pkg:deb/debian/example-package@1.2.3": {
+                         "concluded": {
+                             "identifiers": [ "GPL-3.0-or-later" ],
+                             "expression": "GPL-3.0-or-later",
+                             "name": "GNU General Public License v3.0 or later",
+                             "category": "STRONG_COPYLEFT",
+                             "source": "deps.dev",
+                             "sourceUrl": "https://api.deps.dev"
+                         },
+                         "evidence": []
+                     }
                  }
              }
          ]
      }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies that the test data for OCI images is unrealistic, as it contains Maven license information. This improves test quality and ensures the logic for different artifact types is validated correctly.

Medium
Guard against missing licenseDetails

Add null and type checks for the licenseDetails node before casting and
iteration to prevent errors from malformed responses.

src/main/java/io/github/guacsec/trustifyda/integration/licenses/DepsDevResponseHandler.java [117-120]

 if (result.has("version")) {
   var version = result.get("version");
-  var licensesNode = (ArrayNode) version.get("licenseDetails");
-  licensesNode.forEach(
+  JsonNode licensesNode = version.get("licenseDetails");
+  if (licensesNode != null && licensesNode.isArray()) {
+    ArrayNode licenseDetails = (ArrayNode) licensesNode;
+    licenseDetails.forEach(
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: This suggestion improves robustness by adding a check for the existence and type of the licenseDetails JSON node, preventing potential NullPointerException or ClassCastException from malformed API responses.

Medium
correct table column span

Correct the numRenderedColumns prop in ConditionalTableBody to match the actual
number of columns.

ui/src/components/DepCompoundTable.tsx [157-159]

 <ConditionalTableBody
     isNoData={filteredItems.length === 0}
-    numRenderedColumns={8}
+    numRenderedColumns={5}
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: This is a valid and important fix for the UI, ensuring the 'no data' message spans the correct number of columns in the table, which prevents a broken layout.

Medium
Default to UNKNOWN category

Modify resolveCategory to return CategoryEnum.UNKNOWN as a default value if no
licenses are parsed, preventing a null return.

src/main/java/io/github/guacsec/trustifyda/integration/licenses/DepsDevResponseHandler.java [217-233]

 for (var license : licenses) {
   var newCategory = getCategory(license);
   if (category == null) {
     category = newCategory;
   } else {
     if (isOrExpression) {
       if (isMorePermissive(newCategory, category)) {
         category = newCategory;
       }
     } else {
       if (isMorePermissive(category, newCategory)) {
         category = newCategory;
       }
     }
   }
 }
-return category;
+return category != null ? category : CategoryEnum.UNKNOWN;
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: This suggestion correctly identifies a scenario where resolveCategory could return null if no licenses are found, and provides a safe default of UNKNOWN, improving the method's robustness.

Low
General
Use camelCase summary fields

Update the LicenseSummary TypeScript interface to use camelCase for
strongCopyleft and weakCopyleft to match the backend JSON payload.

ui/src/api/report.ts [66-73]

 export interface LicenseSummary {
   total: number;
   concluded: number;
   permissive: number;
-  "strong-copyleft": number;
+  strongCopyleft: number;
   unknown: number;
-  "weak-copyleft": number;
+  weakCopyleft: number;
 }
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: This suggestion correctly identifies a mismatch between the frontend TypeScript interface and the backend JSON payload for license summary fields, which would cause data binding issues. The fix aligns the interface with the camelCase fields from the backend.

Medium
sort by defined category order

Implement sorting for the license category column based on the predefined sort
order.

ui/src/components/LicensesTable.tsx [89-93]

 case 3: {
-  const aVal = a.concluded?.category || '';
-  const bVal = b.concluded?.category || '';
-  return aVal.localeCompare(bVal);
+  const aIdx = getCategorySortIndex(a.concluded?.category);
+  const bIdx = getCategorySortIndex(b.concluded?.category);
+  return aIdx - bIdx;
 }
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly points out that sorting by category should use the defined business logic (getCategorySortIndex) rather than alphabetical order, which improves the user experience by presenting data logically.

Medium
Fix inconsistent license category handling

Ensure consistent handling of unknown license categories in getCategoryLabel and
getCategoryColor functions.

ui/src/components/LicensesCountByCategory.tsx [21-31]

 export function getCategoryLabel(category: string | undefined): string {
   if (!category) return CATEGORY_LABELS.UNKNOWN;
   const cat = category.toUpperCase().replace(/-/g, '_');
-  return CATEGORY_LABELS[cat] ?? category;
+  return CATEGORY_LABELS[cat] ?? CATEGORY_LABELS.UNKNOWN;
 }
 
 export function getCategoryColor(category: string | undefined): string {
   if (!category) return CATEGORY_COLORS.UNKNOWN;
   const cat = category.toUpperCase().replace(/-/g, '_');
   return CATEGORY_COLORS[cat] ?? CATEGORY_COLORS.UNKNOWN;
 }
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies and fixes an inconsistency in handling unknown license categories, improving UI predictability and robustness.

Low
use integer grid spans

Ensure grid column spans are integers by using Math.floor for calculations.

ui/src/components/SummaryCard.tsx [56-59]

-const firstRowSpan = (12 / firstRowCount) as GridItemProps['md'];
-const secondRowSpan = (12 / secondRowCount) as GridItemProps['md'];
+const firstRowSpan = Math.floor(12 / firstRowCount) as GridItemProps['md'];
+const secondRowSpan = Math.floor(12 / secondRowCount) as GridItemProps['md'];
  • Apply / Chat
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that grid span values should be integers and proposes using Math.floor, which is a valid fix to prevent potential layout issues.

Low
  • Update

@ruromero ruromero marked this pull request as ready for review February 9, 2026 08:12
@qodo-code-review
Copy link
Contributor

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🟡
🎫 #560
🟢 Include license information in the Analysis Report.
🔴 Implement the /license endpoint to generate a report of the licenses defined for the given
purls.
Align implementation with the spec changes described in
https://github.com/guacsec/trustify-da-api-spec/issues/95 (including any required
request/response schema changes).
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status:
Misleading comment: The comment claims there are only "dependencies table and vulnerabilities table"
while the assertion now expects 3 tables, making the test documentation inaccurate.

Referred Code
DomNodeList<DomElement> tables = page.getElementsByTagName("table");
assertEquals(3, tables.size()); // dependencies table and vulnerabilities table
DomElement table = tables.get(0); // dependencies table

Learn more about managing compliance generic rules or creating your own custom rules

Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

Signed-off-by: Ruben Romero Montes <rromerom@redhat.com>
@qodo-code-review
Copy link
Contributor

qodo-code-review bot commented Feb 9, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
High-level
Abstract table logic into reusable component
Suggestion Impact:Both LicensesTable and DepCompoundTable were refactored to delegate shared table behaviors to a new GenericCompoundTable component by defining column definitions and passing filter/sort/expand configuration instead of duplicating the full table implementation in each file.

code diff:

# File: ui/src/components/LicensesTable.tsx
@@ -1,46 +1,14 @@
-import React, {useState} from 'react';
-import {
-  Card,
-  CardBody,
-  Divider,
-  EmptyState,
-  EmptyStateBody,
-  EmptyStateHeader,
-  EmptyStateIcon,
-  EmptyStateVariant,
-  Icon,
-  SearchInput,
-  Toolbar,
-  ToolbarContent,
-  ToolbarItem,
-  ToolbarItemVariant,
-  ToolbarToggleGroup,
-} from '@patternfly/react-core';
-import {
-  ExpandableRowContent,
-  Table,
-  TableVariant,
-  Tbody,
-  Td,
-  TdProps,
-  Th,
-  Thead,
-  Tr,
-} from '@patternfly/react-table';
-import FilterIcon from '@patternfly/react-icons/dist/esm/icons/filter-icon';
-import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon';
+import React from 'react';
+import { Divider, Icon } from '@patternfly/react-core';
 import SecurityIcon from '@patternfly/react-icons/dist/esm/icons/security-icon';
-import {LicensePackageReport} from '../api/report';
-import {getCategoryColor, getCategoryLabel} from './LicensesCountByCategory';
-import {useTable} from '../hooks/useTable';
-import {useTableControls} from '../hooks/useTableControls';
-import {SimplePagination} from './TableControls/SimplePagination';
-import {DependencyLink} from './DependencyLink';
-import {LicensesCountByCategory} from './LicensesCountByCategory';
-import {extractDependencyVersion} from '../utils/utils';
-import {ConditionalTableBody} from './TableControls/ConditionalTableBody';
-import {ConcludedLicenseDetail} from './ConcludedLicenseDetail';
-import {EvidenceLicensesTable} from './EvidenceLicensesTable';
+import { LicensePackageReport } from '../api/report';
+import { getCategoryColor, getCategoryLabel } from './LicensesCountByCategory';
+import { GenericCompoundTable, ColumnDef } from './GenericCompoundTable';
+import { DependencyLink } from './DependencyLink';
+import { LicensesCountByCategory } from './LicensesCountByCategory';
+import { extractDependencyVersion } from '../utils/utils';
+import { ConcludedLicenseDetail } from './ConcludedLicenseDetail';
+import { EvidenceLicensesTable } from './EvidenceLicensesTable';
 
 export interface LicenseTableRow {
   ref: string;
@@ -48,7 +16,9 @@
   evidence: LicensePackageReport['evidence'];
 }
 
-function packagesToRows(packages: { [key: string]: LicensePackageReport }): LicenseTableRow[] {
+function packagesToRows(packages: {
+  [key: string]: LicensePackageReport;
+}): LicenseTableRow[] {
   return Object.entries(packages || {}).map(([ref, pkg]) => ({
     ref,
     concluded: pkg.concluded,
@@ -63,240 +33,119 @@
   name: string;
   dependencies: { [key: string]: LicensePackageReport };
 }) => {
-  const [filterText, setFilterText] = useState('');
   const dependencies = packagesToRows(packages);
 
-  const {
-    page: currentPage,
-    sortBy: currentSortBy,
-    changePage: onPageChange,
-    changeSortBy: onChangeSortBy,
-  } = useTableControls();
-
-  const {pageItems, filteredItems} = useTable({
-    items: dependencies,
-    currentPage: currentPage,
-    currentSortBy: currentSortBy,
-    compareToByColumn: (a: LicenseTableRow, b: LicenseTableRow, columnIndex?: number) => {
-      switch (columnIndex) {
-        case 1:
-          return a.ref.localeCompare(b.ref);
-        case 2: {
-          const aVal = a.concluded?.expression || a.concluded?.name || '';
-          const bVal = b.concluded?.expression || b.concluded?.name || '';
-          return aVal.localeCompare(bVal);
-        }
-        case 3: {
-          const aVal = a.concluded?.category || '';
-          const bVal = b.concluded?.category || '';
-          return aVal.localeCompare(bVal);
-        }
-        default:
-          return 0;
-      }
+  const columns: ColumnDef<LicenseTableRow>[] = [
+    {
+      key: 'name',
+      header: 'Dependency Name',
+      width: 30,
+      sortIndex: 1,
+      render: (item) => <DependencyLink name={item.ref} />,
     },
-    filterItem: (item) => {
-      if (!filterText || filterText.trim().length === 0) return true;
-      return item.ref.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+    {
+      key: 'version',
+      header: 'Current Version',
+      width: 15,
+      render: (item) => extractDependencyVersion(item.ref),
     },
-  });
-
-  const columnNames = {
-    name: 'Dependency Name',
-    version: 'Current Version',
-    concluded: 'Concluded',
-    category: 'Category',
-    licenses: 'Licenses',
-  };
-  type ColumnKey = keyof typeof columnNames;
-
-  const [expandedCells, setExpandedCells] = useState<Record<string, ColumnKey>>({});
-  const setCellExpanded = (row: LicenseTableRow, columnKey: ColumnKey, isExpanding = true) => {
-    const newExpanded = { ...expandedCells };
-    if (isExpanding) {
-      newExpanded[row.ref] = columnKey;
-    } else {
-      delete newExpanded[row.ref];
-    }
-    setExpandedCells(newExpanded);
-  };
-
-  const compoundExpandParams = (
-    row: LicenseTableRow,
-    columnKey: ColumnKey,
-    rowIndex: number,
-    columnIndex: number
-  ): TdProps['compoundExpand'] => ({
-    isExpanded: expandedCells[row.ref] === columnKey,
-    onToggle: () => setCellExpanded(row, columnKey, expandedCells[row.ref] !== columnKey),
-    expandId: 'licenses-compound-expand',
-    rowIndex,
-    columnIndex,
-  });
+    {
+      key: 'concluded',
+      header: 'Concluded',
+      width: 20,
+      sortIndex: 2,
+      compoundExpand: true,
+      render: (item) =>
+        item.concluded
+          ? item.concluded.expression || item.concluded.name || '—'
+          : '—',
+    },
+    {
+      key: 'category',
+      header: 'Category',
+      width: 15,
+      sortIndex: 3,
+      render: (item) =>
+        item.concluded?.category ? (
+          <span>
+            <Icon isInline>
+              <SecurityIcon
+                style={{
+                  fill: getCategoryColor(item.concluded.category),
+                  height: '13px',
+                }}
+              />
+            </Icon>
+            &nbsp;
+            {getCategoryLabel(item.concluded.category)}
+          </span>
+        ) : (
+          '—'
+        ),
+    },
+    {
+      key: 'licenses',
+      header: 'Licenses',
+      width: 25,
+      compoundExpand: true,
+      render: (item) =>
+        item.evidence?.length ? (
+          <div style={{ display: 'flex', alignItems: 'center' }}>
+            <div style={{ width: '25px' }}>{item.evidence.length}</div>
+            <Divider orientation={{ default: 'vertical' }} style={{ paddingRight: '10px' }} />
+            <LicensesCountByCategory evidence={item.evidence} />
+          </div>
+        ) : (
+          0
+        ),
+    },
+  ];
 
   return (
-    <Card>
-      <CardBody>
-        <div style={{backgroundColor: 'var(--pf-v5-global--BackgroundColor--100)'}}>
-          <Toolbar>
-            <ToolbarContent>
-              <ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="xl">
-                <ToolbarItem variant="search-filter">
-                  <SearchInput
-                    id={name + '-license-filter'}
-                    style={{width: '250px'}}
-                    placeholder="Filter by Dependency name"
-                    value={filterText}
-                    onChange={(_, value) => setFilterText(value)}
-                    onClear={() => setFilterText('')}
-                  />
-                </ToolbarItem>
-              </ToolbarToggleGroup>
-              <ToolbarItem variant={ToolbarItemVariant.pagination} align={{default: 'alignRight'}}>
-                <SimplePagination
-                  isTop={true}
-                  count={filteredItems.length}
-                  params={currentPage}
-                  onChange={onPageChange}
-                />
-              </ToolbarItem>
-            </ToolbarContent>
-          </Toolbar>
-          <Table aria-label={(name ?? 'Licenses') + ' licenses'} variant={TableVariant.compact}>
-            <Thead>
-              <Tr>
-                <Th
-                  width={25}
-                  sort={{
-                    columnIndex: 1,
-                    sortBy: {...currentSortBy},
-                    onSort: onChangeSortBy,
-                  }}
-                >
-                  {columnNames.name}
-                </Th>
-                <Th>{columnNames.version}</Th>
-                <Th
-                  width={20}
-                  sort={{
-                    columnIndex: 2,
-                    sortBy: {...currentSortBy},
-                    onSort: onChangeSortBy,
-                  }}
-                >
-                  {columnNames.concluded}
-                </Th>
-                <Th
-                  width={15}
-                  sort={{
-                    columnIndex: 3,
-                    sortBy: {...currentSortBy},
-                    onSort: onChangeSortBy,
-                  }}
-                >
-                  {columnNames.category}
-                </Th>
-                <Th>{columnNames.licenses}</Th>
-              </Tr>
-            </Thead>
-            <ConditionalTableBody
-              isNoData={filteredItems.length === 0}
-              numRenderedColumns={5}
-              noDataEmptyState={
-                <EmptyState variant={EmptyStateVariant.sm}>
-                  <EmptyStateHeader
-                    icon={<EmptyStateIcon icon={SearchIcon} />}
-                    titleText="No results found"
-                    headingLevel="h2"
-                  />
-                  <EmptyStateBody>Clear all filters and try again.</EmptyStateBody>
-                </EmptyState>
-              }
-            >
-              {pageItems?.map((item, rowIndex) => {
-                const expandedCellKey = expandedCells[item.ref];
-                const isRowExpanded = !!expandedCellKey;
-                return (
-                  <Tbody key={item.ref} isExpanded={isRowExpanded}>
-                    <Tr>
-                      <Td width={30} dataLabel={columnNames.name} component="th">
-                        <DependencyLink name={item.ref} />
-                      </Td>
-                      <Td width={15} dataLabel={columnNames.version}>
-                        {extractDependencyVersion(item.ref)}
-                      </Td>
-                      <Td
-                        width={20}
-                        dataLabel={columnNames.concluded}
-                        compoundExpand={compoundExpandParams(item, 'concluded', rowIndex, 2)}
-                      >
-                        {item.concluded ? (
-                          item.concluded.expression || item.concluded.name || '—'
-                        ) : (
-                          '—'
-                        )}
-                      </Td>
-                      <Td width={15} dataLabel={columnNames.category}>
-                        {item.concluded?.category ? (
-                          <span>
-                            <Icon isInline>
-                              <SecurityIcon style={{fill: getCategoryColor(item.concluded.category), height: '13px'}} />
-                            </Icon>
-                            &nbsp;
-                            {getCategoryLabel(item.concluded.category)}
-                          </span>
-                        ) : (
-                          '—'
-                        )}
-                      </Td>
-                      <Td
-                        width={25}
-                        dataLabel={columnNames.licenses}
-                        compoundExpand={compoundExpandParams(item, 'licenses', rowIndex, 4)}
-                      >
-                        {item.evidence?.length ? (
-                          <div style={{display: 'flex', alignItems: 'center'}}>
-                            <div style={{width: '25px'}}>{item.evidence.length}</div>
-                            <Divider
-                              orientation={{default: 'vertical'}}
-                              style={{paddingRight: '10px'}}
-                            />
-                            <LicensesCountByCategory evidence={item.evidence} />
-                          </div>
-                        ) : (
-                          0
-                        )}
-                      </Td>
-                    </Tr>
-                    {isRowExpanded ? (
-                      <Tr isExpanded={isRowExpanded}>
-                        <Td dataLabel={columnNames[expandedCellKey]} noPadding colSpan={5}>
-                          <ExpandableRowContent>
-                            <div className="pf-v5-u-m-md">
-                              {expandedCellKey === 'concluded' && item.concluded ? (
-                                <ConcludedLicenseDetail concluded={item.concluded} />
-                              ) : expandedCellKey === 'licenses' && item.evidence?.length ? (
-                                <EvidenceLicensesTable evidence={item.evidence} />
-                              ) : null}
-                            </div>
-                          </ExpandableRowContent>
-                        </Td>
-                      </Tr>
-                    ) : null}
-                  </Tbody>
-                );
-              })}
-            </ConditionalTableBody>
-          </Table>
-          <SimplePagination
-            isTop={false}
-            count={filteredItems.length}
-            params={currentPage}
-            onChange={onPageChange}
-          />
-        </div>
-      </CardBody>
-    </Card>
+    <GenericCompoundTable<LicenseTableRow>
+      name={name}
+      items={dependencies}
+      getRowKey={(item) => item.ref}
+      columns={columns}
+      filterConfig={{
+        placeholder: 'Filter by Dependency name',
+        idSuffix: '-license-filter',
+      }}
+      compareToByColumn={(a, b, columnIndex) => {
+        switch (columnIndex) {
+          case 1:
+            return a.ref.localeCompare(b.ref);
+          case 2: {
+            const aVal = a.concluded?.expression || a.concluded?.name || '';
+            const bVal = b.concluded?.expression || b.concluded?.name || '';
+            return aVal.localeCompare(bVal);
+          }
+          case 3: {
+            const aVal = a.concluded?.category || '';
+            const bVal = b.concluded?.category || '';
+            return aVal.localeCompare(bVal);
+          }
+          default:
+            return 0;
+        }
+      }}
+      filterItem={(item, filterText) => {
+        if (!filterText || filterText.trim().length === 0) return true;
+        return item.ref.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+      }}
+      renderExpandContent={(item, expandedColumnKey) => {
+        if (expandedColumnKey === 'concluded' && item.concluded) {
+          return <ConcludedLicenseDetail concluded={item.concluded} />;
+        }
+        if (expandedColumnKey === 'licenses' && item.evidence?.length) {
+          return <EvidenceLicensesTable evidence={item.evidence} />;
+        }
+        return null;
+      }}
+      ariaLabelPrefix="Licenses"
+      expandId="licenses-compound-expand"
+      initialSortBy={{ index: 3, direction: 'desc' }}
+    />
   );
 };

# File: ui/src/components/DepCompoundTable.tsx
@@ -1,273 +1,133 @@
-import React, {useState} from 'react';
-import {
-  Card,
-  CardBody,
-  Divider,
-  EmptyState,
-  EmptyStateBody,
-  EmptyStateHeader,
-  EmptyStateIcon,
-  EmptyStateVariant,
-  SearchInput,
-  Toolbar,
-  ToolbarContent,
-  ToolbarItem,
-  ToolbarItemVariant,
-  ToolbarToggleGroup,
-} from '@patternfly/react-core';
-import {ExpandableRowContent, Table, TableVariant, Tbody, Td, TdProps, Th, Thead, Tr} from '@patternfly/react-table';
-import FilterIcon from '@patternfly/react-icons/dist/esm/icons/filter-icon';
-import {Dependency} from '../api/report';
-import {useTable} from '../hooks/useTable';
-import {useTableControls} from '../hooks/useTableControls';
-import {SimplePagination} from './TableControls/SimplePagination';
-import {DependencyLink} from './DependencyLink';
-import {TransitiveDependenciesTable} from './TransitiveDependenciesTable';
-import {VulnerabilitiesTable} from './VulnerabilitiesTable';
-import {VulnerabilitiesCountBySeverity} from './VulnerabilitiesCountBySeverity'
-import {extractDependencyVersion} from '../utils/utils';
-import SearchIcon from "@patternfly/react-icons/dist/esm/icons/search-icon";
-import {ConditionalTableBody} from './TableControls/ConditionalTableBody';
-import {RemediationsAvailability} from "./RemediationsAvailability";
+import React from 'react';
+import { Divider } from '@patternfly/react-core';
+import { Dependency } from '../api/report';
+import { GenericCompoundTable, ColumnDef } from './GenericCompoundTable';
+import { DependencyLink } from './DependencyLink';
+import { TransitiveDependenciesTable } from './TransitiveDependenciesTable';
+import { VulnerabilitiesTable } from './VulnerabilitiesTable';
+import { VulnerabilitiesCountBySeverity } from './VulnerabilitiesCountBySeverity';
+import { extractDependencyVersion } from '../utils/utils';
+import { RemediationsAvailability } from './RemediationsAvailability';
 
-export const DepCompoundTable = ({name, dependencies}: { name: string; dependencies: Dependency[] }) => {
-  // Filters
-  const [filterText, setFilterText] = useState('');
-
-  // Rows
-  const {
-    page: currentPage,
-    sortBy: currentSortBy,
-    changePage: onPageChange,
-    changeSortBy: onChangeSortBy,
-  } = useTableControls();
-
-  const {pageItems, filteredItems} = useTable({
-    items: dependencies,
-    currentPage: currentPage,
-    currentSortBy: currentSortBy,
-    compareToByColumn: (a: Dependency, b: Dependency, columnIndex?: number) => {
-      switch (columnIndex) {
-        case 1:
-          return a.ref.localeCompare(b.ref);
-        default:
-          return 0;
-      }
+export const DepCompoundTable = ({
+  name,
+  dependencies,
+}: {
+  name: string;
+  dependencies: Dependency[];
+}) => {
+  const columns: ColumnDef<Dependency>[] = [
+    {
+      key: 'name',
+      header: 'Dependency Name',
+      width: 30,
+      sortIndex: 1,
+      render: (item) => <DependencyLink name={item.ref} />,
     },
-    filterItem: (item) => {
-      let isFilterTextFilterCompliant = true;
-      if (filterText && filterText.trim().length > 0) {
-        isFilterTextFilterCompliant =
-          item.ref.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
-      }
-
-      return isFilterTextFilterCompliant;
+    {
+      key: 'version',
+      header: 'Current Version',
+      width: 15,
+      render: (item) => extractDependencyVersion(item.ref),
     },
-  });
-
-  const columnNames = {
-    name: 'Dependency Name',
-    version: 'Current Version',
-    direct: 'Direct Vulnerabilities',
-    transitive: 'Transitive Vulnerabilities',
-    rhRemediation: 'Remediation available'
-  };
-  type ColumnKey = keyof typeof columnNames;
-
-  // In this example, expanded cells are tracked by the repo and property names from each row. This could be any pair of unique identifiers.
-  // This is to prevent state from being based on row and column order index in case we later add sorting and rearranging columns.
-  // Note that this behavior is very similar to selection state.
-  const [expandedCells, setExpandedCells] = React.useState<Record<string, ColumnKey>>({
-    'siemur/test-space': 'name' // Default to the first cell of the first row being expanded
-  });
-  const setCellExpanded = (repo: Dependency, columnKey: ColumnKey, isExpanding = true) => {
-    const newExpandedCells = {...expandedCells};
-    if (isExpanding) {
-      newExpandedCells[repo.ref] = columnKey;
-    } else {
-      delete newExpandedCells[repo.ref];
-    }
-    setExpandedCells(newExpandedCells);
-  };
-  const compoundExpandParams = (
-    dependency: Dependency,
-    columnKey: ColumnKey,
-    rowIndex: number,
-    columnIndex: number
-  ): TdProps['compoundExpand'] => ({
-    isExpanded: expandedCells[dependency.ref] === columnKey,
-    onToggle: () => setCellExpanded(dependency, columnKey, expandedCells[dependency.ref] !== columnKey),
-    expandId: 'compound-expandable-example',
-    rowIndex,
-    columnIndex
-  });
+    {
+      key: 'direct',
+      header: 'Direct Vulnerabilities',
+      width: 15,
+      compoundExpand: true,
+      render: (item) =>
+        item.issues?.length ? (
+          <div style={{ display: 'flex', alignItems: 'center' }}>
+            <div style={{ width: '25px' }}>{item.issues?.length}</div>
+            <Divider
+              orientation={{ default: 'vertical' }}
+              style={{ paddingRight: '10px' }}
+            />
+            <VulnerabilitiesCountBySeverity vulnerabilities={item.issues} />
+          </div>
+        ) : (
+          0
+        ),
+    },
+    {
+      key: 'transitive',
+      header: 'Transitive Vulnerabilities',
+      width: 15,
+      compoundExpand: true,
+      render: (item) =>
+        item.transitive?.length ? (
+          <div style={{ display: 'flex', alignItems: 'center' }}>
+            <div style={{ width: '25px' }}>
+              {item.transitive
+                .map((e) => e.issues?.length)
+                .reduce((prev = 0, current = 0) => prev + current)}
+            </div>
+            <Divider
+              orientation={{ default: 'vertical' }}
+              style={{ paddingRight: '10px' }}
+            />
+            <VulnerabilitiesCountBySeverity transitiveDependencies={item.transitive} />
+          </div>
+        ) : (
+          0
+        ),
+    },
+    {
+      key: 'rhRemediation',
+      header: 'Remediation available',
+      width: 15,
+      render: (item) => <RemediationsAvailability dependency={item} />,
+    },
+  ];
 
   return (
-    <Card>
-      <CardBody>
-        <div
-          style={{
-            backgroundColor: 'var(--pf-v5-global--BackgroundColor--100)',
-          }}
-        >
-          <Toolbar>
-            <ToolbarContent>
-              <ToolbarToggleGroup toggleIcon={<FilterIcon/>} breakpoint="xl">
-                <ToolbarItem variant="search-filter">
-                  <SearchInput
-                    id={name + '-dependency-filter'}
-                    style={{width: '250px'}}
-                    placeholder="Filter by Dependency name"
-                    value={filterText}
-                    onChange={(_, value) => setFilterText(value)}
-                    onClear={() => setFilterText('')}
-                  />
-                </ToolbarItem>
-              </ToolbarToggleGroup>
-              <ToolbarItem
-                variant={ToolbarItemVariant.pagination}
-                align={{default: 'alignRight'}}
-              >
-                <SimplePagination
-                  isTop={true}
-                  count={filteredItems.length}
-                  params={currentPage}
-                  onChange={onPageChange}
-                />
-              </ToolbarItem>
-            </ToolbarContent>
-          </Toolbar>
-          <Table aria-label={(name ?? "Default") + " dependencies"} variant={TableVariant.compact}>
-            <Thead>
-              <Tr>
-                <Th width={25}
-                    sort={{
-                      columnIndex: 1,
-                      sortBy: {...currentSortBy},
-                      onSort: onChangeSortBy,
-                    }}
-                >{columnNames.name}</Th>
-                <Th>{columnNames.version}</Th>
-                <Th>{columnNames.direct}</Th>
-                <Th>{columnNames.transitive}</Th>
-                <Th>{columnNames.rhRemediation}</Th>
-              </Tr>
-            </Thead>
-            <ConditionalTableBody
-                isNoData={filteredItems.length === 0}
-                numRenderedColumns={8}
-                noDataEmptyState={
-                  <EmptyState variant={EmptyStateVariant.sm}>
-                    <EmptyStateHeader
-                        icon={<EmptyStateIcon icon={SearchIcon} />}
-                        titleText="No results found"
-                        headingLevel="h2"
-                    />
-                    <EmptyStateBody>Clear all filters and try again.</EmptyStateBody>
-                  </EmptyState>
-                }
-            >
-            {pageItems?.map((item, rowIndex) => {
-              const expandedCellKey = expandedCells[item.ref];
-              const isRowExpanded = !!expandedCellKey;
-              return (
-                (item.issues?.length || item.transitive?.length ) ? (
-                  <Tbody key={item.ref} isExpanded={isRowExpanded}>
-                  <Tr>
-                    <Td width={30} dataLabel={columnNames.name} component="th">
-                      <DependencyLink name={item.ref}/>
-                    </Td>
-                    <Td
-                      width={15}
-                      dataLabel={columnNames.version}
-                    >
-                      {extractDependencyVersion(item.ref)}
-                    </Td>
-                    <Td
-                      width={15}
-                      dataLabel={columnNames.direct}
-                      compoundExpand={compoundExpandParams(item, 'direct', rowIndex, 2)}
-                    >
-                      {(item.issues?.length) ? (
-                        <div style={{ display: 'flex', alignItems: 'center' }}>
-                          <div style={{ width: '25px' }}>{item.issues?.length}</div>
-                          <Divider
-                            orientation={{
-                              default: 'vertical'
-                            }} style={{paddingRight: '10px'}}
-                          />
-                          <VulnerabilitiesCountBySeverity vulnerabilities={item.issues}/>
-                        </div>
-                      ) : 0}
-                    </Td>
-                    <Td
-                      width={15}
-                      dataLabel={columnNames.transitive}
-                      compoundExpand={compoundExpandParams(item, 'transitive', rowIndex, 3)}
-                    >
-                      {(item.transitive?.length) ? (
-                        <div style={{ display: 'flex', alignItems: 'center' }}>
-                          <div style={{ width: '25px' }}>
-                            {item.transitive
-                              .map(e => e.issues?.length)
-                              .reduce((prev = 0, current = 0) => prev + current)}
-                          </div>
-                          <Divider
-                            orientation={{
-                              default: 'vertical',
-                            }} style={{paddingRight: '10px'}}
-                          />
-                          <VulnerabilitiesCountBySeverity transitiveDependencies={item.transitive} />
-                        </div>
-                      ) : 0}
-                    </Td>
-                    <Td width={15}
-                        dataLabel={columnNames.rhRemediation}
-                    >
-                      <RemediationsAvailability dependency={item} />
-                    </Td>
-                  </Tr>
-                  {isRowExpanded ? (
-                    <Tr isExpanded={isRowExpanded}>
-                      <Td dataLabel={columnNames[expandedCellKey]} noPadding colSpan={6}>
-                        <ExpandableRowContent>
-                          <div className="pf-v5-u-m-md">
-                            {(expandedCellKey === 'direct' && item.issues && item.issues.length > 0) ? (
-                              // Content for direct column
-                              <VulnerabilitiesTable
-                                providerName={name}
-                                dependency={item}
-                                vulnerabilities={item.issues}
-                              />
-                            ) : (expandedCellKey === 'transitive' && item.transitive && item.transitive.length > 0) ? (
-                              // Content for transitive column
-                              <TransitiveDependenciesTable
-                                providerName={name}
-                                transitiveDependencies={item.transitive}
-                              />
-                            ) : null}
-                          </div>
-                        </ExpandableRowContent>
-                      </Td>
-                    </Tr>
-                  ) : (null)
-                  }
-                </Tbody> ): null
-              );
-            })}
-            </ConditionalTableBody>
-
-          </Table>
-
-          <SimplePagination
-            isTop={false}
-            count={filteredItems.length}
-            params={currentPage}
-            onChange={onPageChange}
-          />
-        </div>
-      </CardBody>
-    </Card>
+    <GenericCompoundTable<Dependency>
+      name={name}
+      items={dependencies}
+      getRowKey={(item) => item.ref}
+      columns={columns}
+      filterConfig={{
+        placeholder: 'Filter by Dependency name',
+        idSuffix: '-dependency-filter',
+      }}
+      compareToByColumn={(a, b, columnIndex) => {
+        switch (columnIndex) {
+          case 1:
+            return a.ref.localeCompare(b.ref);
+          default:
+            return 0;
+        }
+      }}
+      filterItem={(item, filterText) => {
+        const hasContent = !!(item.issues?.length || item.transitive?.length);
+        if (!hasContent) return false;
+        if (!filterText || filterText.trim().length === 0) return true;
+        return item.ref.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+      }}
+      renderExpandContent={(item, expandedColumnKey) => {
+        if (expandedColumnKey === 'direct' && item.issues?.length) {
+          return (
+            <VulnerabilitiesTable
+              providerName={name}
+              dependency={item}
+              vulnerabilities={item.issues}
+            />
+          );
+        }
+        if (expandedColumnKey === 'transitive' && item.transitive?.length) {
+          return (
+            <TransitiveDependenciesTable
+              providerName={name}
+              transitiveDependencies={item.transitive}
+            />
+          );
+        }
+        return null;
+      }}
+      ariaLabelPrefix="Dependencies"
+      expandId="compound-expandable-example"
+      defaultExpanded={{ 'siemur/test-space': 'name' }}
+    />
   );
 };

The new LicensesTable duplicates table logic (filtering, sorting, pagination,
expansion) from DepCompoundTable. Abstract this shared functionality into a
reusable generic table component to reduce duplication and improve
maintainability.

Examples:

ui/src/components/LicensesTable.tsx [59-135]
export const LicensesTable = ({
  name,
  dependencies: packages,
}: {
  name: string;
  dependencies: { [key: string]: LicensePackageReport };
}) => {
  const [filterText, setFilterText] = useState('');
  const dependencies = packagesToRows(packages);


 ... (clipped 67 lines)
ui/src/components/DepCompoundTable.tsx [34-109]
export const DepCompoundTable = ({name, dependencies}: { name: string; dependencies: Dependency[] }) => {
  // Filters
  const [filterText, setFilterText] = useState('');

  // Rows
  const {
    page: currentPage,
    sortBy: currentSortBy,
    changePage: onPageChange,
    changeSortBy: onChangeSortBy,

 ... (clipped 66 lines)

Solution Walkthrough:

Before:

// In LicensesTable.tsx
export const LicensesTable = ({ dependencies }) => {
  const [filterText, setFilterText] = useState('');
  const { page, sortBy, changePage, changeSortBy } = useTableControls();
  const { pageItems, filteredItems } = useTable({ /* ... */ });
  const [expandedCells, setExpandedCells] = useState({});
  // ... compound expand logic ...

  return (
    <Toolbar>...</Toolbar>
    <Table>
      {/* ... map over pageItems to render rows and expandable content ... */}
    </Table>
    <SimplePagination />
  );
};

// In DepCompoundTable.tsx (similar structure)
export const DepCompoundTable = ({ dependencies }) => {
  const [filterText, setFilterText] = useState('');
  const { page, sortBy, changePage, changeSortBy } = useTableControls();
  const { pageItems, filteredItems } = useTable({ /* ... */ });
  const [expandedCells, setExpandedCells] = useState({});
  // ... compound expand logic ...

  return (
    <Toolbar>...</Toolbar>
    <Table>
      {/* ... map over pageItems to render rows and expandable content ... */}
    </Table>
    <SimplePagination />
  );
};

After:

// A new reusable component
const GenericCompoundTable = ({ items, columns, filterConfig, expandConfig }) => {
  const [filterText, setFilterText] = useState('');
  const { page, sortBy, changePage, changeSortBy } = useTableControls();
  const { pageItems, filteredItems } = useTable({ items, /* ... */ });
  const [expandedCells, setExpandedCells] = useState({});
  // ... generic compound expand logic ...

  return (
    <Toolbar>...</Toolbar>
    <Table>
      {/* ... render rows and expandable content based on props ... */}
    </Table>
    <SimplePagination />
  );
};

// Simplified LicensesTable.tsx
export const LicensesTable = ({ dependencies }) => {
  return <GenericCompoundTable items={dependencies} columns={licenseColumns} ... />;
};

// Simplified DepCompoundTable.tsx
export const DepCompoundTable = ({ dependencies }) => {
  return <GenericCompoundTable items={dependencies} columns={depColumns} ... />;
};
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies significant code duplication for table functionalities like sorting, filtering, and pagination between the new LicensesTable and the existing DepCompoundTable, proposing a valuable architectural improvement.

High
General
add key to dynamic tabs
Suggestion Impact:Added key={license.status.name} to the dynamically created pushed into the tabs array.

code diff:

   report.licenses?.forEach((license) => {
-    tabs.push(<Tab
+    tabs.push(<Tab 
+      key={license.status.name}
       eventKey={license.status.name}
       title={<TabTitleText>{license.status.name}</TabTitleText>}

Add a unique key prop to the dynamically generated components to satisfy
React's list rendering requirements.

ui/src/components/TabbedLayout.tsx [65-70]

 report.licenses?.forEach((license) => {
-  tabs.push(<Tab
+  tabs.push(<Tab key={license.status.name}
     eventKey={license.status.name}
     title={<TabTitleText>{license.status.name}</TabTitleText>}
     aria-label={`${license.status.name} source`}
   >

[Suggestion processed]

Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a missing key prop in a list of React components, which is essential for performance and avoiding runtime warnings.

Medium
Use constants for chart colors
Suggestion Impact:The commit imports CATEGORY_COLORS and replaces the previous customColors/ChartThemeColor-based color mapping with CATEGORY_COLORS for legend fills and the donut chart colorScale (via Object.values).

code diff:

@@ -1,8 +1,7 @@
 import {Bullseye, CardBody} from '@patternfly/react-core';
-import {ChartDonut, ChartThemeColor} from '@patternfly/react-charts';
+import {ChartDonut} from '@patternfly/react-charts';
 import {LicenseSummary} from '../api/report';
-
-const customColors = [ChartThemeColor.blue,ChartThemeColor.green,ChartThemeColor.gold,ChartThemeColor.orange];
+import {CATEGORY_COLORS} from './LicensesCountByCategory';
 
 export const LicensesChartCard = ({summary}: { summary: LicenseSummary }) => {
 
@@ -13,14 +12,12 @@
   const concluded = summary["concluded"] ?? 0;
 
   const hasValues = permissive + strongCopyleft + unknown + weakCopyleft > 0;
-  const zeroColor = '#D5F5E3';
-  const colorScale = hasValues ? customColors : [zeroColor];
 
   const legendData = [
-    {name: `Permissive: ${permissive}`, symbol: {type: 'square', fill: customColors[0]}},
-    {name: `Weak Copyleft: ${weakCopyleft}`, symbol: {type: 'square', fill: customColors[1]}},
-    {name: `Strong Copyleft: ${strongCopyleft}`, symbol: {type: 'square', fill: customColors[2]}},
-    {name: `Unknown: ${unknown}`, symbol: {type: 'square', fill: customColors[3]}},
+    {name: `Permissive: ${permissive}`, symbol: {type: 'square', fill: CATEGORY_COLORS.PERMISSIVE}},
+    {name: `Weak Copyleft: ${weakCopyleft}`, symbol: {type: 'square', fill: CATEGORY_COLORS.WEAK_COPYLEFT}},
+    {name: `Strong Copyleft: ${strongCopyleft}`, symbol: {type: 'square', fill: CATEGORY_COLORS.STRONG_COPYLEFT}},
+    {name: `Unknown: ${unknown}`, symbol: {type: 'square', fill: CATEGORY_COLORS.UNKNOWN}},
   ];
 
   return (
@@ -48,7 +45,7 @@
               subTitle="Concluded licenses"
               title={`${concluded}`}
               width={350}
-              colorScale={colorScale}
+              colorScale={Object.values(CATEGORY_COLORS)}
             />

Refactor the customColors array to use the CATEGORY_COLORS constants from
LicensesCountByCategory.tsx. This ensures consistent color mapping for license
categories.

ui/src/components/LicensesChartCard.tsx [5]

-const customColors = [ChartThemeColor.blue,ChartThemeColor.green,ChartThemeColor.gold,ChartThemeColor.orange];
+import {CATEGORY_COLORS} from './LicensesCountByCategory';
 
+const customColors = [CATEGORY_COLORS.PERMISSIVE, CATEGORY_COLORS.WEAK_COPYLEFT, CATEGORY_COLORS.STRONG_COPYLEFT, CATEGORY_COLORS.UNKNOWN];
+

[Suggestion processed]

Suggestion importance[1-10]: 5

__

Why: The suggestion correctly points out a fragile implementation and proposes using shared constants for colors, which improves code maintainability and robustness.

Low
Correct inconsistent mock license data
Suggestion Impact:Updated the mock license summary fields for "strong-copyleft" and "unknown" to 0 to align with the detailed package information (the suggested "total" change was not included).

code diff:

@@ -379,8 +379,8 @@
           "concluded": 10,
           "permissive": 8,
           "weak-copyleft": 2,
-          "strong-copyleft": 10,
-          "unknown": 2
+          "strong-copyleft": 0,
+          "unknown": 0
         },

Correct the license summary in the mock data to align with the detailed package
information. Update the strong-copyleft, unknown, and total counts to be
accurate.

ui/src/mocks/reportBasic.mock.ts [377-384]

 "summary": {
-  "total": 12,
+  "total": 10,
   "concluded": 10,
   "permissive": 8,
   "weak-copyleft": 2,
-  "strong-copyleft": 10,
-  "unknown": 2
+  "strong-copyleft": 0,
+  "unknown": 0
 },

[Suggestion processed]

Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies and fixes inconsistencies in the mock data, which improves the reliability of development and testing.

Low
round grid spans to integers
Suggestion Impact:Updated both firstRowSpan and secondRowSpan calculations to use Math.floor and clamp the result between 1 and 12, preventing fractional grid spans.

code diff:

-  const firstRowSpan = (12 / firstRowCount) as GridItemProps['md'];
+  const firstRowSpan = Math.min(12, Math.max(1, Math.floor(12 / firstRowCount))) as GridItemProps['md'];
+  
   // Second row: Remediations, Container recommendations, Explore
   const secondRowCount = 1 + Number(showContainerRecommendationsCard) + Number(showExploreCard);
-  const secondRowSpan = (12 / secondRowCount) as GridItemProps['md'];
+  const secondRowSpan = Math.min(12, Math.max(1, Math.floor(12 / secondRowCount))) as GridItemProps['md'];

Update the grid span calculations to ensure they produce integer values. Use
Math.floor to round down and clamp the result between 1 and 12.

ui/src/components/SummaryCard.tsx [58-59]

-const firstRowSpan = (12 / firstRowCount) as GridItemProps['md'];
-const secondRowSpan = (12 / secondRowCount) as GridItemProps['md'];
+const firstRowSpan = Math.min(12, Math.max(1, Math.floor(12 / firstRowCount))) as GridItemProps['md'];
+const secondRowSpan = Math.min(12, Math.max(1, Math.floor(12 / secondRowCount))) as GridItemProps['md'];

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 3

__

Why: The suggestion addresses a potential issue where grid span values could be fractional, which is not ideal. While the current code doesn't produce fractions, this change makes the calculation more robust for future modifications.

Low
Possible issue
Fix incorrect license category sorting

Fix the sorting logic for the 'Category' column in the licenses table. Use the
getCategorySortIndex helper function to sort by permissiveness instead of
alphabetically.

ui/src/components/LicensesTable.tsx [76-102]

 const {pageItems, filteredItems} = useTable({
   items: dependencies,
   currentPage: currentPage,
   currentSortBy: currentSortBy,
   compareToByColumn: (a: LicenseTableRow, b: LicenseTableRow, columnIndex?: number) => {
     switch (columnIndex) {
       case 1:
         return a.ref.localeCompare(b.ref);
       case 2: {
         const aVal = a.concluded?.expression || a.concluded?.name || '';
         const bVal = b.concluded?.expression || b.concluded?.name || '';
         return aVal.localeCompare(bVal);
       }
       case 3: {
-        const aVal = a.concluded?.category || '';
-        const bVal = b.concluded?.category || '';
-        return aVal.localeCompare(bVal);
+        return getCategorySortIndex(a.concluded?.category) - getCategorySortIndex(b.concluded?.category);
       }
       default:
         return 0;
     }
   },
   filterItem: (item) => {
     if (!filterText || filterText.trim().length === 0) return true;
     return item.ref.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
   },
 });
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a logic bug in the table sorting for the 'Category' column and provides a fix that implements the intended sorting behavior, improving usability.

Medium
Handle empty string for category

Update getCategoryLabel and getCategoryColor to handle empty or whitespace-only
string inputs for category by defaulting to the 'UNKNOWN' category.

ui/src/components/LicensesCountByCategory.tsx [21-31]

 export function getCategoryLabel(category: string | undefined): string {
-  if (!category) return CATEGORY_LABELS.UNKNOWN;
+  if (!category || category.trim() === '') return CATEGORY_LABELS.UNKNOWN;
   const cat = category.toUpperCase().replace(/-/g, '_');
   return CATEGORY_LABELS[cat] ?? category;
 }
 
 export function getCategoryColor(category: string | undefined): string {
-  if (!category) return CATEGORY_COLORS.UNKNOWN;
+  if (!category || category.trim() === '') return CATEGORY_COLORS.UNKNOWN;
   const cat = category.toUpperCase().replace(/-/g, '_');
   return CATEGORY_COLORS[cat] ?? CATEGORY_COLORS.UNKNOWN;
 }
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies a potential edge case with empty strings that could lead to minor UI rendering issues and provides a robust fix.

Low
  • More

@qodo-code-review
Copy link
Contributor

Failed to generate code suggestions for PR

@ruromero ruromero changed the title Oss license report feat: html license report Feb 9, 2026
Signed-off-by: Ruben Romero Montes <rromerom@redhat.com>
@ruromero ruromero merged commit c984e96 into guacsec:main Feb 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Implement OSS-License report

2 participants