diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 7275eddf62..75a6c27bf5 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -270,6 +270,8 @@ "jobFunction": "Job function", "jobFunctionDeleted": "Job function deleted", "jobFunctions": "Job functions", + "language": "Language", + "label": "Label", "loading": "Loading", "lowRisk": "Low risk", "mavenConfig": "Maven configuration", diff --git a/client/src/app/Constants.ts b/client/src/app/Constants.ts index ef67164e43..682ba2dc10 100644 --- a/client/src/app/Constants.ts +++ b/client/src/app/Constants.ts @@ -241,4 +241,5 @@ export enum TableURLParamKeyPrefix { issuesAffectedApps = "ia", issuesAffectedFiles = "if", issuesRemainingIncidents = "ii", + dependencyApplications = "da", } diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index e06a099fd7..1f450f372a 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -557,10 +557,27 @@ export interface TrackerProjectIssuetype { export interface AnalysisDependency { createTime: string; name: string; + provider: string; version: string; - // TODO where did these properties go? - // indirect?: boolean; - // applications: { id: number; name: string }[]; + sha: string; + applications: number; + labels: string[]; +} + +export interface AnalysisAppDependency { + id: number; + name: string; + description: string; + businessService: string; + dependency: { + id: number; + name: string; + version: string; + provider: string; + indirect: boolean; + //TODO: Glean from labels somehow + // management?: string; + }; } interface AnalysisIssuesCommonFields { diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 1a834cc307..43fc805001 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -6,7 +6,6 @@ import { BaseAnalysisRuleReport, BaseAnalysisIssueReport, AnalysisIssue, - AnalysisAppReport, AnalysisFileReport, AnalysisIncident, Application, @@ -47,6 +46,8 @@ import { TrackerProjectIssuetype, Fact, UnstructuredFact, + AnalysisAppDependency, + AnalysisAppReport, } from "./models"; import { QueryKey } from "@tanstack/react-query"; import { serializeRequestParamsForHub } from "@app/shared/hooks/table-controls"; @@ -86,7 +87,7 @@ export const RULESETS = HUB + "/rulesets"; export const FILES = HUB + "/files"; export const CACHE = HUB + "/cache/m2"; -export const ANALYSIS_DEPENDENCIES = HUB + "/analyses/dependencies"; +export const ANALYSIS_DEPENDENCIES = HUB + "/analyses/report/dependencies"; export const ANALYSIS_REPORT_RULES = HUB + "/analyses/report/rules"; export const ANALYSIS_REPORT_ISSUES_APPS = HUB + "/analyses/report/issues/applications"; @@ -94,6 +95,11 @@ export const ANALYSIS_REPORT_APP_ISSUES = HUB + "/analyses/report/applications/:applicationId/issues"; export const ANALYSIS_REPORT_ISSUE_FILES = HUB + "/analyses/report/issues/:issueId/files"; + +export const ANALYSIS_REPORT_APP_DEPENDENCIES = + HUB + "/analyses/report/dependencies/applications"; + +export const ANALYSIS_REPORT_FILES = HUB + "/analyses/report/issues/:id/files"; export const ANALYSIS_ISSUES = HUB + "/analyses/issues"; export const ANALYSIS_ISSUE_INCIDENTS = HUB + "/analyses/issues/:issueId/incidents"; @@ -616,8 +622,7 @@ export const getIssueReports = ( ANALYSIS_REPORT_APP_ISSUES.replace( "/:applicationId/", `/${String(applicationId)}/` - ), - params + ) ); export const getIssues = (params: HubRequestParams = {}) => @@ -653,6 +658,12 @@ export const getIncidents = (issueId?: number, params: HubRequestParams = {}) => export const getDependencies = (params: HubRequestParams = {}) => getHubPaginatedResult(ANALYSIS_DEPENDENCIES, params); +export const getAppDependencies = (params: HubRequestParams = {}) => + getHubPaginatedResult( + ANALYSIS_REPORT_APP_DEPENDENCIES, + params + ); + // Tickets export const createTickets = (payload: New, applications: Ref[]) => { const promises: AxiosPromise[] = []; diff --git a/client/src/app/pages/dependencies/dependencies.tsx b/client/src/app/pages/dependencies/dependencies.tsx index 2f00ffdb21..522646f9e0 100644 --- a/client/src/app/pages/dependencies/dependencies.tsx +++ b/client/src/app/pages/dependencies/dependencies.tsx @@ -1,5 +1,8 @@ import * as React from "react"; import { + Button, + Label, + LabelGroup, PageSection, PageSectionVariants, Text, @@ -27,24 +30,67 @@ import { import { useFetchDependencies } from "@app/queries/dependencies"; import { useSelectionState } from "@migtools/lib-ui"; import { getHubRequestParams } from "@app/shared/hooks/table-controls"; -import { PageDrawerContent } from "@app/shared/page-drawer-context"; +import { DependencyAppsDetailDrawer } from "./dependency-apps-detail-drawer"; +import { useSharedAffectedApplicationFilterCategories } from "../issues/helpers"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; export const Dependencies: React.FC = () => { const { t } = useTranslation(); + const allAffectedApplicationsFilterCategories = + useSharedAffectedApplicationFilterCategories(); + const tableControlState = useTableControlUrlParams({ columnNames: { name: "Dependency name", foundIn: "Found in", + provider: "Language", + labels: "Labels", + sha: "SHA", version: "Version", }, - sortableColumns: ["name", "version"], - initialSort: null, + sortableColumns: ["name", "foundIn", "labels"], + initialSort: { columnKey: "name", direction: "asc" }, filterCategories: [ + ...allAffectedApplicationsFilterCategories, { key: "name", title: t("terms.name"), type: FilterType.search, + filterGroup: "Dependency", + placeholderText: + t("actions.filterBy", { + what: t("terms.name").toLowerCase(), + }) + "...", + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + key: "provider", + title: t("terms.language"), + type: FilterType.search, + filterGroup: "Dependency", + placeholderText: + t("actions.filterBy", { + what: t("terms.language").toLowerCase(), + }) + "...", + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + key: "version", + title: t("terms.version"), + type: FilterType.search, + filterGroup: "Dependency", + placeholderText: + t("actions.filterBy", { + what: t("terms.label").toLowerCase(), + }) + "...", + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + key: "sha", + title: "SHA", + type: FilterType.search, + filterGroup: "Dependency", placeholderText: t("actions.filterBy", { what: t("terms.name").toLowerCase(), @@ -66,7 +112,8 @@ export const Dependencies: React.FC = () => { ...tableControlState, // Includes filterState, sortState and paginationState hubSortFieldKeys: { name: "name", - version: "version", + foundIn: "applications", + labels: "labels", }, }) ); @@ -95,7 +142,7 @@ export const Dependencies: React.FC = () => { getTdProps, getClickableTrProps, }, - activeRowDerivedState: { activeRowItem, clearActiveRow }, + activeRowDerivedState: { activeRowItem, clearActiveRow, setActiveRowItem }, } = tableControls; return ( @@ -129,7 +176,10 @@ export const Dependencies: React.FC = () => { + + + @@ -155,9 +205,35 @@ export const Dependencies: React.FC = () => { width={10} {...getTdProps({ columnKey: "foundIn" })} > - {/* TODO - the applications property disappeared in the API? */} - {/*dependency.applications.length} applications*/} - TODO + + + + {dependency.provider} + + + + {dependency?.labels?.map((label) => { + return ; + })} + { > {dependency.version} + + {dependency.sha} + @@ -179,14 +258,10 @@ export const Dependencies: React.FC = () => { /> - - TODO details about dependency {activeRowItem?.name} here! - + setActiveRowItem(null)} + > ); }; diff --git a/client/src/app/pages/dependencies/dependency-apps-detail-drawer.tsx b/client/src/app/pages/dependencies/dependency-apps-detail-drawer.tsx new file mode 100644 index 0000000000..7c74914eb1 --- /dev/null +++ b/client/src/app/pages/dependencies/dependency-apps-detail-drawer.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { + IPageDrawerContentProps, + PageDrawerContent, +} from "@app/shared/page-drawer-context"; +import { + TextContent, + Text, + Title, + Tabs, + TabTitleText, + Tab, +} from "@patternfly/react-core"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import { AnalysisDependency } from "@app/api/models"; +import { StateNoData } from "@app/shared/components/app-table/state-no-data"; +import { DependencyAppsTable } from "./dependency-apps-table"; + +export interface IDependencyAppsDetailDrawerProps + extends Pick { + dependency: AnalysisDependency | null; +} + +enum TabKey { + Applications = 0, +} + +export const DependencyAppsDetailDrawer: React.FC< + IDependencyAppsDetailDrawerProps +> = ({ dependency, onCloseClick }) => { + const [activeTabKey, setActiveTabKey] = React.useState( + TabKey.Applications + ); + + return ( + + {!dependency ? ( + + ) : ( + <> + + + Dependencies + + + {dependency?.name || ""} + + + setActiveTabKey(tabKey as TabKey)} + className={spacing.mtLg} + > + Applications} + > + {dependency ? ( + + ) : null} + + + + )} + + ); +}; diff --git a/client/src/app/pages/dependencies/dependency-apps-table.tsx b/client/src/app/pages/dependencies/dependency-apps-table.tsx new file mode 100644 index 0000000000..2035d1ec3b --- /dev/null +++ b/client/src/app/pages/dependencies/dependency-apps-table.tsx @@ -0,0 +1,219 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import { useSelectionState } from "@migtools/lib-ui"; +import { AnalysisDependency } from "@app/api/models"; +import { + getHubRequestParams, + useTableControlProps, + useTableControlUrlParams, +} from "@app/shared/hooks/table-controls"; +import { TableURLParamKeyPrefix } from "@app/Constants"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, + TableRowContentWithControls, +} from "@app/shared/components/table-controls"; +import { SimplePagination } from "@app/shared/components/simple-pagination"; +import { + FilterToolbar, + FilterType, +} from "@app/shared/components/FilterToolbar"; +import { useFetchAppDependencies } from "@app/queries/dependencies"; +import { useFetchBusinessServices } from "@app/queries/businessservices"; +import { useFetchTags } from "@app/queries/tags"; + +export interface IDependencyAppsTableProps { + dependency: AnalysisDependency; +} + +export const DependencyAppsTable: React.FC = ({ + dependency, +}) => { + const { t } = useTranslation(); + const { businessServices } = useFetchBusinessServices(); + const { tags } = useFetchTags(); + + const tableControlState = useTableControlUrlParams({ + urlParamKeyPrefix: TableURLParamKeyPrefix.dependencyApplications, + columnNames: { + name: "Application", + version: "Version", + // management (3rd party or not boolean... parsed from labels) + relationship: "Relationship", + }, + sortableColumns: ["name", "version"], + initialSort: { columnKey: "name", direction: "asc" }, + filterCategories: [ + { + key: "name", + title: "Application Name", + type: FilterType.search, + placeholderText: + t("actions.filterBy", { + what: "name", // TODO i18n + }) + "...", + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + key: "businessService", + title: t("terms.businessService"), + placeholderText: + t("actions.filterBy", { + what: t("terms.businessService").toLowerCase(), + }) + "...", + type: FilterType.select, + selectOptions: businessServices + .map((businessService) => businessService.name) + .map((name) => ({ key: name, value: name })), + }, + { + key: "tag.id", + title: t("terms.tags"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.tagName").toLowerCase(), + }) + "...", + selectOptions: [...new Set(tags.map((tag) => tag.name))].map( + (tagName) => ({ key: tagName, value: tagName }) + ), + }, + ], + initialItemsPerPage: 10, + }); + + const { + result: { data: currentPageAppDependencies, total: totalItemCount }, + isFetching, + fetchError, + } = useFetchAppDependencies( + getHubRequestParams({ + ...tableControlState, + hubSortFieldKeys: { + name: "name", + version: "version", + }, + implicitFilters: [ + { field: "dep.name", operator: "=", value: dependency.name }, + { field: "dep.version", operator: "=", value: dependency.version }, + { field: "dep.sha", operator: "=", value: dependency.sha }, + ], + }) + ); + + const tableControls = useTableControlProps({ + ...tableControlState, + idProperty: "name", + currentPageItems: currentPageAppDependencies, + totalItemCount, + isLoading: isFetching, + // TODO FIXME - we don't need selectionState but it's required by this hook? + selectionState: useSelectionState({ + items: currentPageAppDependencies, + isEqual: (a, b) => a.name === b.name, + }), + }); + + const { + numRenderedColumns, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTdProps, + }, + } = tableControls; + + return ( + <> + + + + + + + + + + + + + + + + + {currentPageAppDependencies?.map((appDependency, rowIndex) => ( + + + + + {/* */} + + + + ))} + + +
+ + {/* */} + + +
+ {appDependency.name} + + {appDependency.dependency.version} + + {appDependency.management} + + {appDependency.dependency.indirect + ? "Transitive" + : "Direct"} +
+ + + ); +}; diff --git a/client/src/app/pages/issues/affected-applications/affected-applications.tsx b/client/src/app/pages/issues/affected-applications/affected-applications.tsx index d8d48bf2e1..d77cc79d39 100644 --- a/client/src/app/pages/issues/affected-applications/affected-applications.tsx +++ b/client/src/app/pages/issues/affected-applications/affected-applications.tsx @@ -30,7 +30,7 @@ import { FilterToolbar } from "@app/shared/components/FilterToolbar"; import { useSelectionState } from "@migtools/lib-ui"; import { getBackToAllIssuesUrl, - useSharedFilterCategoriesForIssuesAndAffectedApps, + useSharedAffectedApplicationFilterCategories, } from "../helpers"; import { IssueDetailDrawer } from "../issue-detail-drawer"; import { TableURLParamKeyPrefix } from "@app/Constants"; @@ -59,7 +59,7 @@ export const AffectedApplications: React.FC = () => { }, sortableColumns: ["name", "businessService", "effort", "incidents"], initialSort: { columnKey: "name", direction: "asc" }, - filterCategories: useSharedFilterCategoriesForIssuesAndAffectedApps(), + filterCategories: useSharedAffectedApplicationFilterCategories(), initialItemsPerPage: 10, // TODO PF V5 obsolete // hasClickableRows: true, diff --git a/client/src/app/pages/issues/helpers.ts b/client/src/app/pages/issues/helpers.ts index 12e52de9a1..26f7ae7768 100644 --- a/client/src/app/pages/issues/helpers.ts +++ b/client/src/app/pages/issues/helpers.ts @@ -31,62 +31,64 @@ export type IssuesFilterValuesToCarry = Partial< Record >; -export const useSharedFilterCategoriesForIssuesAndAffectedApps = - (): FilterCategory[] => { - const { t } = useTranslation(); - const { tags } = useFetchTags(); - const { businessServices } = useFetchBusinessServices(); +export const useSharedAffectedApplicationFilterCategories = (): FilterCategory< + unknown, + IssuesFilterKeyToCarry +>[] => { + const { t } = useTranslation(); + const { tags } = useFetchTags(); + const { businessServices } = useFetchBusinessServices(); - return [ - { - key: "application.name", - title: t("terms.applicationName"), - filterGroup: IssueFilterGroups.ApplicationInventory, - type: FilterType.search, - placeholderText: - t("actions.filterBy", { - what: t("terms.applicationName").toLowerCase(), - }) + "...", - getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), - }, - { - key: "businessService.name", - title: t("terms.businessService"), - filterGroup: IssueFilterGroups.ApplicationInventory, - placeholderText: - t("actions.filterBy", { - what: t("terms.businessService").toLowerCase(), - }) + "...", - type: FilterType.select, - selectOptions: businessServices - .map((businessService) => businessService.name) - .map((name) => ({ key: name, value: name })), - }, - { - key: "tag.id", - title: t("terms.tags"), - filterGroup: IssueFilterGroups.ApplicationInventory, - type: FilterType.multiselect, - placeholderText: - t("actions.filterBy", { - what: t("terms.tagName").toLowerCase(), - }) + "...", - selectOptions: [...new Set(tags.map((tag) => tag.name))].map( - (tagName) => ({ key: tagName, value: tagName }) + return [ + { + key: "application.name", + title: t("terms.applicationName"), + filterGroup: IssueFilterGroups.ApplicationInventory, + type: FilterType.search, + placeholderText: + t("actions.filterBy", { + what: t("terms.applicationName").toLowerCase(), + }) + "...", + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + key: "businessService.name", + title: t("terms.businessService"), + filterGroup: IssueFilterGroups.ApplicationInventory, + placeholderText: + t("actions.filterBy", { + what: t("terms.businessService").toLowerCase(), + }) + "...", + type: FilterType.select, + selectOptions: businessServices + .map((businessService) => businessService.name) + .map((name) => ({ key: name, value: name })), + }, + { + key: "tag.id", + title: t("terms.tags"), + filterGroup: IssueFilterGroups.ApplicationInventory, + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.tagName").toLowerCase(), + }) + "...", + selectOptions: [...new Set(tags.map((tag) => tag.name))].map( + (tagName) => ({ key: tagName, value: tagName }) + ), + // NOTE: The same tag name can appear in multiple tag categories. + // To replicate the behavior of the app inventory page, selecting a tag name + // will perform an OR filter matching all tags with that name across tag categories. + // In the future we may instead want to present the tag select options to the user in category sections. + getServerFilterValue: (tagNames) => + tagNames?.flatMap((tagName) => + tags + .filter((tag) => tag.name === tagName) + .map((tag) => String(tag.id)) ), - // NOTE: The same tag name can appear in multiple tag categories. - // To replicate the behavior of the app inventory page, selecting a tag name - // will perform an OR filter matching all tags with that name across tag categories. - // In the future we may instead want to present the tag select options to the user in category sections. - getServerFilterValue: (tagNames) => - tagNames?.flatMap((tagName) => - tags - .filter((tag) => tag.name === tagName) - .map((tag) => String(tag.id)) - ), - }, - ]; - }; + }, + ]; +}; const FROM_ISSUES_PARAMS_KEY = "~fromIssuesParams"; // ~ prefix sorts it at the end of the URL for readability diff --git a/client/src/app/pages/issues/issues-table.tsx b/client/src/app/pages/issues/issues-table.tsx index 15b515f198..ce413b92b3 100644 --- a/client/src/app/pages/issues/issues-table.tsx +++ b/client/src/app/pages/issues/issues-table.tsx @@ -56,9 +56,9 @@ import { } from "@app/shared/hooks/table-controls"; import { - useSharedFilterCategoriesForIssuesAndAffectedApps, parseReportLabels, getIssuesSingleAppSelectedLocation, + useSharedAffectedApplicationFilterCategories, } from "./helpers"; import { IssueFilterGroups } from "./issues"; import { @@ -94,7 +94,7 @@ export const IssuesTable: React.FC = ({ mode }) => { }; const allIssuesSpecificFilterCategories = - useSharedFilterCategoriesForIssuesAndAffectedApps(); + useSharedAffectedApplicationFilterCategories(); const tableControlState = useTableControlUrlParams({ urlParamKeyPrefix: TableURLParamKeyPrefix.issues, diff --git a/client/src/app/queries/dependencies.ts b/client/src/app/queries/dependencies.ts index c14457d484..a32b3f61b5 100644 --- a/client/src/app/queries/dependencies.ts +++ b/client/src/app/queries/dependencies.ts @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { + AnalysisAppDependency, AnalysisDependency, HubPaginatedResult, HubRequestParams, } from "@app/api/models"; -import { getDependencies } from "@app/api/rest"; +import { getAppDependencies, getDependencies } from "@app/api/rest"; import { serializeRequestParamsForHub } from "@app/shared/hooks/table-controls/getHubRequestParams"; export interface IDependenciesFetchState { @@ -13,8 +14,15 @@ export interface IDependenciesFetchState { fetchError: unknown; refetch: () => void; } +export interface IAppDependenciesFetchState { + result: HubPaginatedResult; + isFetching: boolean; + fetchError: unknown; + refetch: () => void; +} export const DependenciesQueryKey = "dependencies"; +export const AppDependenciesQueryKey = "appdependencies"; export const useFetchDependencies = ( params: HubRequestParams = {} @@ -35,3 +43,23 @@ export const useFetchDependencies = ( refetch, }; }; + +export const useFetchAppDependencies = ( + params: HubRequestParams = {} +): IAppDependenciesFetchState => { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: [ + AppDependenciesQueryKey, + serializeRequestParamsForHub(params).toString(), + ], + queryFn: async () => await getAppDependencies(params), + onError: (error) => console.log("error, ", error), + keepPreviousData: true, + }); + return { + result: data || { data: [], total: 0, params }, + isFetching: isLoading, + fetchError: error, + refetch, + }; +};