From d3e824ee9a2b46e525df62ca1433cf1cecbe092d Mon Sep 17 00:00:00 2001 From: David Vail Date: Mon, 13 Mar 2023 15:09:16 -0400 Subject: [PATCH 1/2] Add pagination and sorting to image single table --- .../ImageSingleVulnerabilities.tsx | 102 +++++++++--------- .../SummaryCards/BySeveritySummaryCard.tsx | 35 +++--- .../SummaryCards/CvesByStatusSummaryCard.tsx | 24 ++++- .../SingleEntityVulnerabilitiesTable.tsx | 10 +- .../hooks/useImageVulnerabilities.ts | 45 +++++++- ui/apps/platform/src/hooks/useURLSort.ts | 6 +- 6 files changed, 141 insertions(+), 81 deletions(-) diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx index 467b8635f1a2..5f80cd836a37 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx @@ -11,6 +11,7 @@ import { GridItem, Label, PageSection, + Pagination, pluralize, Spinner, Split, @@ -28,52 +29,24 @@ import { VulnerabilitySeverity } from 'types/cve.proto'; import { getAxiosErrorMessage } from 'utils/responseErrorUtils'; import useURLStringUnion from 'hooks/useURLStringUnion'; import useURLSearch from 'hooks/useURLSearch'; +import useURLPagination from 'hooks/useURLPagination'; +import useURLSort, { UseURLSortProps } from 'hooks/useURLSort'; import { getHasSearchApplied } from 'utils/searchUtils'; import { cveStatusTabValues, FixableStatus } from './types'; import WorkloadTableToolbar from './WorkloadTableToolbar'; import BySeveritySummaryCard from './SummaryCards/BySeveritySummaryCard'; import CvesByStatusSummaryCard from './SummaryCards/CvesByStatusSummaryCard'; import SingleEntityVulnerabilitiesTable from './Tables/SingleEntityVulnerabilitiesTable'; -import useImageVulnerabilities, { - ImageVulnerabilitiesResponse, -} from './hooks/useImageVulnerabilities'; import { ImageDetailsResponse } from './hooks/useImageDetails'; +import useImageVulnerabilities from './hooks/useImageVulnerabilities'; -function severityCountsFromImageVulnerabilities( - imageVulnerabilities: ImageVulnerabilitiesResponse['image']['imageVulnerabilities'] -): Record { - const severityCounts = { - LOW_VULNERABILITY_SEVERITY: 0, - MODERATE_VULNERABILITY_SEVERITY: 0, - IMPORTANT_VULNERABILITY_SEVERITY: 0, - CRITICAL_VULNERABILITY_SEVERITY: 0, - }; - - imageVulnerabilities.forEach(({ severity }) => { - severityCounts[severity] += 1; - }); - - return severityCounts; -} - -function statusCountsFromImageVulnerabilities( - imageVulnerabilities: ImageVulnerabilitiesResponse['image']['imageVulnerabilities'] -): Record { - const statusCounts = { - Fixable: 0, - 'Not fixable': 0, - }; - - imageVulnerabilities.forEach(({ isFixable }) => { - if (isFixable) { - statusCounts.Fixable += 1; - } else { - statusCounts['Not fixable'] += 1; - } - }); - - return statusCounts; -} +const defaultSortOptions: UseURLSortProps = { + sortFields: ['CVE', 'Severity', 'Fixable'], + defaultSortOption: { + field: 'Severity', + direction: 'desc', + }, +}; export type ImageSingleVulnerabilitiesProps = { imageId: string; @@ -82,13 +55,23 @@ export type ImageSingleVulnerabilitiesProps = { function ImageSingleVulnerabilities({ imageId, imageData }: ImageSingleVulnerabilitiesProps) { const { searchFilter } = useURLSearch(); + const { page, perPage, setPage, setPerPage } = useURLPagination(50); + // TODO Need to reset current page at the same time sorting changes + const { sortOption, getSortParams } = useURLSort(defaultSortOptions); // TODO Still need to properly integrate search filter with query - const { data, loading, error } = useImageVulnerabilities(imageId, {}); + const pagination = { + offset: (page - 1) * perPage, + limit: perPage, + sortOption, + }; + const { data, previousData, loading, error } = useImageVulnerabilities(imageId, {}, pagination); const [activeTabKey, setActiveTabKey] = useURLStringUnion('cveStatus', cveStatusTabValues); let mainContent: ReactNode | null = null; + const vulnerabilityData = data || previousData; + if (error) { mainContent = ( @@ -102,20 +85,21 @@ function ImageSingleVulnerabilities({ imageId, imageData }: ImageSingleVulnerabi ); - } else if (loading && !data) { + } else if (loading && !vulnerabilityData) { mainContent = ( ); - } else if (data) { - const vulnerabilities = data.image.imageVulnerabilities; - const severityCounts = severityCountsFromImageVulnerabilities(vulnerabilities); - const cveStatusCounts = statusCountsFromImageVulnerabilities(vulnerabilities); + } else if (vulnerabilityData) { + const vulnerabilities = vulnerabilityData.image.imageVulnerabilities; + // TODO Integrate these with page search filters const hiddenSeverities = new Set([]); const hiddenStatuses = new Set([]); + const totalVulnerabilityCount = vulnerabilityData.image.imageVulnerabilityCounter.all.total; + mainContent = ( <>
@@ -123,13 +107,13 @@ function ImageSingleVulnerabilities({ imageId, imageData }: ImageSingleVulnerabi @@ -141,12 +125,7 @@ function ImageSingleVulnerabilities({ imageId, imageData }: ImageSingleVulnerabi - {pluralize( - data.image.imageVulnerabilities.length, - 'result', - 'results' - )}{' '} - found + {pluralize(totalVulnerabilityCount, 'result', 'results')} found {getHasSearchApplied(searchFilter) && ( - TODO Pagination + + setPage(newPage)} + onPerPageSelect={(_, newPerPage) => { + if (totalVulnerabilityCount < (page - 1) * newPerPage) { + setPage(1); + } + setPerPage(newPerPage); + }} + /> +
diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/BySeveritySummaryCard.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/BySeveritySummaryCard.tsx index 60c7074b4955..66672b48dbc6 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/BySeveritySummaryCard.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/BySeveritySummaryCard.tsx @@ -5,19 +5,26 @@ import SeverityIcons from 'Components/PatternFly/SeverityIcons'; import { VulnerabilitySeverity } from 'types/cve.proto'; import { vulnerabilitySeverityLabels } from 'messages/common'; +import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon'; +import { + ImageVulnerabilityCounter, + ImageVulnerabilityCounterKey, +} from '../hooks/useImageVulnerabilities'; export type BySeveritySummaryCardProps = { title: string; - severityCounts: Record; + severityCounts: ImageVulnerabilityCounter; hiddenSeverities: Set; }; -const severitiesCriticalToLow = [ - 'CRITICAL_VULNERABILITY_SEVERITY', - 'IMPORTANT_VULNERABILITY_SEVERITY', - 'MODERATE_VULNERABILITY_SEVERITY', - 'LOW_VULNERABILITY_SEVERITY', -] as const; +const vulnCounterToSeverity: Record = { + low: 'LOW_VULNERABILITY_SEVERITY', + moderate: 'MODERATE_VULNERABILITY_SEVERITY', + important: 'IMPORTANT_VULNERABILITY_SEVERITY', + critical: 'CRITICAL_VULNERABILITY_SEVERITY', +} as const; + +const severitiesCriticalToLow = ['critical', 'important', 'moderate', 'low'] as const; const disabledColor100 = 'var(--pf-global--disabled-color--100)'; const disabledColor200 = 'var(--pf-global--disabled-color--200)'; @@ -34,11 +41,12 @@ function BySeveritySummaryCard({ {severitiesCriticalToLow.map((severity) => { const count = severityCounts[severity]; - const hasNoResults = count === 0; - const isHidden = hiddenSeverities.has(severity); + const hasNoResults = count.total === 0; + const vulnSeverity = vulnCounterToSeverity[severity]; + const isHidden = hiddenSeverities.has(vulnSeverity); let textColor = ''; - let text = `${count} ${vulnerabilitySeverityLabels[severity]}`; + let text = `${count.total} ${vulnerabilitySeverityLabels[vulnSeverity]}`; if (isHidden) { textColor = disabledColor100; @@ -48,16 +56,17 @@ function BySeveritySummaryCard({ text = 'No results'; } - const Icon = SeverityIcons[severity]; + const Icon: React.FC | undefined = + SeverityIcons[vulnSeverity]; return ( - + - + {Icon && } {text} diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx index f9d1f85a906e..7e8277a906a9 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx @@ -3,9 +3,13 @@ import { Card, CardTitle, CardBody, Flex, Text, Grid, GridItem } from '@patternf import { CheckCircleIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; import { FixableStatus } from '../types'; +import { + ImageVulnerabilityCounter, + imageVulnerabilityCounterKeys, +} from '../hooks/useImageVulnerabilities'; export type CvesByStatusSummaryCardProps = { - cveStatusCounts: Record; + cveStatusCounts: ImageVulnerabilityCounter; hiddenStatuses: Set; }; @@ -14,15 +18,25 @@ const statusDisplays = [ status: 'Fixable', Icon: CheckCircleIcon, iconColor: 'var(--pf-global--success-color--100)', - text: (counts: CvesByStatusSummaryCardProps['cveStatusCounts']) => - `${counts.Fixable} vulnerabilities with available fixes`, + text: (counts: ImageVulnerabilityCounter) => { + let count = 0; + imageVulnerabilityCounterKeys.forEach((key) => { + count += counts[key].fixable; + }); + return `${count} vulnerabilities with available fixes`; + }, }, { status: 'Not fixable', Icon: ExclamationCircleIcon, iconColor: 'var(--pf-global--danger-color--100)', - text: (counts: CvesByStatusSummaryCardProps['cveStatusCounts']) => - `${counts['Not fixable']} vulnerabilities without fixes`, + text: (counts: ImageVulnerabilityCounter) => { + let count = 0; + imageVulnerabilityCounterKeys.forEach((key) => { + count += counts[key].total - counts[key].fixable; + }); + return `${count} vulnerabilities without fixes`; + }, }, ] as const; diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Tables/SingleEntityVulnerabilitiesTable.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Tables/SingleEntityVulnerabilitiesTable.tsx index 5c343016b7e7..ceeadeb5f1f0 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Tables/SingleEntityVulnerabilitiesTable.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Tables/SingleEntityVulnerabilitiesTable.tsx @@ -17,6 +17,7 @@ import SeverityIcons from 'Components/PatternFly/SeverityIcons'; import useSet from 'hooks/useSet'; import { vulnerabilitySeverityLabels } from 'messages/common'; import { getDistanceStrictAsPhrase } from 'utils/dateUtils'; +import { UseURLSortResult } from 'hooks/useURLSort'; import { ImageVulnerabilitiesResponse } from '../hooks/useImageVulnerabilities'; import { getEntityPagePath } from '../searchUtils'; import ImageComponentsTable from './ImageComponentsTable'; @@ -25,11 +26,13 @@ import { ImageDetailsResponse } from '../hooks/useImageDetails'; export type SingleEntityVulnerabilitiesTableProps = { image: ImageDetailsResponse['image'] | undefined; imageVulnerabilities: ImageVulnerabilitiesResponse['image']['imageVulnerabilities']; + getSortParams: UseURLSortResult['getSortParams']; }; function SingleEntityVulnerabilitiesTable({ image, imageVulnerabilities, + getSortParams, }: SingleEntityVulnerabilitiesTableProps) { const expandedRowSet = useSet(); return ( @@ -37,9 +40,10 @@ function SingleEntityVulnerabilitiesTable({ {/* Header for expanded column */} - CVE - Severity - CVE status + CVE + Severity + CVE Status + {/* TODO Add sorting for these columns once aggregate sorting is available in BE */} Affected components First discovered diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/hooks/useImageVulnerabilities.ts b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/hooks/useImageVulnerabilities.ts index da353d620c98..f6ed1116841d 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/hooks/useImageVulnerabilities.ts +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/hooks/useImageVulnerabilities.ts @@ -1,10 +1,12 @@ import { gql, useQuery } from '@apollo/client'; +import { Pagination } from 'services/types'; import { SearchFilter } from 'types/search'; import { getRequestQueryStringForSearchFilter } from 'utils/searchUtils'; export type ImageVulnerabilitiesVariables = { id: string; vulnQuery: string; + pagination: Pagination; }; export type ImageVulnerabilityComponent = { @@ -16,9 +18,19 @@ export type ImageVulnerabilityComponent = { layerIndex: number | null; }; +export const imageVulnerabilityCounterKeys = ['low', 'moderate', 'important', 'critical'] as const; + +export type ImageVulnerabilityCounterKey = typeof imageVulnerabilityCounterKeys[number]; + +export type ImageVulnerabilityCounter = Record< + ImageVulnerabilityCounterKey | 'all', + { total: number; fixable: number } +>; + export type ImageVulnerabilitiesResponse = { image: { id: string; + imageVulnerabilityCounter: ImageVulnerabilityCounter; imageVulnerabilities: { severity: string; isFixable: boolean; @@ -31,10 +43,32 @@ export type ImageVulnerabilitiesResponse = { }; export const imageVulnerabilitiesQuery = gql` - query getImageVulnerabilities($id: ID!, $vulnQuery: String!) { + query getImageVulnerabilities($id: ID!, $vulnQuery: String!, $pagination: Pagination!) { image(id: $id) { id - imageVulnerabilities(query: $vulnQuery) { + imageVulnerabilityCounter(query: $vulnQuery) { + all { + total + fixable + } + low { + total + fixable + } + moderate { + total + fixable + } + important { + total + fixable + } + critical { + total + fixable + } + } + imageVulnerabilities(query: $vulnQuery, pagination: $pagination) { severity isFixable cve @@ -53,13 +87,18 @@ export const imageVulnerabilitiesQuery = gql` } `; -export default function useImageVulnerabilities(imageId: string, searchFilter: SearchFilter) { +export default function useImageVulnerabilities( + imageId: string, + searchFilter: SearchFilter, + pagination: Pagination +) { return useQuery( imageVulnerabilitiesQuery, { variables: { id: imageId, vulnQuery: getRequestQueryStringForSearchFilter(searchFilter), + pagination, }, } ); diff --git a/ui/apps/platform/src/hooks/useURLSort.ts b/ui/apps/platform/src/hooks/useURLSort.ts index 741de90d5238..3a38ce11a70c 100644 --- a/ui/apps/platform/src/hooks/useURLSort.ts +++ b/ui/apps/platform/src/hooks/useURLSort.ts @@ -6,12 +6,12 @@ import { isParsedQs } from 'utils/queryStringUtils'; export type GetSortParams = (field: string) => ThProps['sort'] | undefined; -type UseTableSortProps = { +export type UseURLSortProps = { sortFields: string[]; defaultSortOption: SortOption; }; -type UseTableSortResult = { +export type UseURLSortResult = { sortOption: ApiSortOption; getSortParams: GetSortParams; }; @@ -27,7 +27,7 @@ function isDirection(val: unknown): val is 'asc' | 'desc' { return val === 'asc' || val === 'desc'; } -function useURLSort({ sortFields, defaultSortOption }: UseTableSortProps): UseTableSortResult { +function useURLSort({ sortFields, defaultSortOption }: UseURLSortProps): UseURLSortResult { const [sortOption, setSortOption] = useURLParameter('sortOption', defaultSortOption); // get the parsed sort option values from the URL, if available From c65b3a8cd07e71f0fb2712e542b9f777af54df78 Mon Sep 17 00:00:00 2001 From: David Vail Date: Fri, 17 Mar 2023 09:50:25 -0400 Subject: [PATCH 2/2] PR feedback --- .../WorkloadCves/ImageSingleVulnerabilities.tsx | 2 +- .../SummaryCards/CvesByStatusSummaryCard.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx index 5f80cd836a37..46adcc9d6b6e 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/ImageSingleVulnerabilities.tsx @@ -70,7 +70,7 @@ function ImageSingleVulnerabilities({ imageId, imageData }: ImageSingleVulnerabi let mainContent: ReactNode | null = null; - const vulnerabilityData = data || previousData; + const vulnerabilityData = data ?? previousData; if (error) { mainContent = ( diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx index 7e8277a906a9..ab8b8c9ac84d 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/SummaryCards/CvesByStatusSummaryCard.tsx @@ -1,5 +1,14 @@ import React from 'react'; -import { Card, CardTitle, CardBody, Flex, Text, Grid, GridItem } from '@patternfly/react-core'; +import { + Card, + CardTitle, + CardBody, + Flex, + Grid, + GridItem, + pluralize, + Text, +} from '@patternfly/react-core'; import { CheckCircleIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; import { FixableStatus } from '../types'; @@ -23,7 +32,7 @@ const statusDisplays = [ imageVulnerabilityCounterKeys.forEach((key) => { count += counts[key].fixable; }); - return `${count} vulnerabilities with available fixes`; + return `${pluralize(count, 'vulnerability', 'vulnerabilities')} with available fixes`; }, }, {