diff --git a/client/src/app/pages/sbom-group-details/components/assessment-results.tsx b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx new file mode 100644 index 000000000..59afb49d6 --- /dev/null +++ b/client/src/app/pages/sbom-group-details/components/assessment-results.tsx @@ -0,0 +1,111 @@ +import type React from "react"; + +import { + Button, + Card, + CardBody, + CardTitle, + Content, + Flex, + FlexItem, + Stack, + StackItem, +} from "@patternfly/react-core"; + +import type { + CategoryResult, + RiskAssessmentResults, +} from "@app/queries/risk-assessments"; +import { useDownloadAssessmentReport } from "@app/queries/risk-assessments"; + +import type { AssessmentCategory } from "./assessment-category-step"; +import { CriteriaSummaryTable } from "./criteria-summary-table"; + +interface AssessmentCategoryResultsProps { + /** The assessment ID for document download. */ + assessmentId: string; + /** The category definition (key, name, description). */ + category: AssessmentCategory; + /** The per-category result data including criteria. */ + categoryResult: CategoryResult; + /** The full results for scoring lookup. */ + overallResults: RiskAssessmentResults; + onStartNewAssessment: () => void; +} + +/** Displays per-category score card and criteria summary for a processed category. */ +export const AssessmentCategoryResults: React.FC< + AssessmentCategoryResultsProps +> = ({ + assessmentId, + category: _category, + categoryResult, + overallResults, + onStartNewAssessment, +}) => { + const { download } = useDownloadAssessmentReport(assessmentId); + + const scorePercent = overallResults.overallScore; + const riskLevel = overallResults.scoring?.overall.riskLevel; + + return ( + + + + Overall Score + + + + + + {scorePercent != null + ? `${Math.round(scorePercent)}%` + : "\u2014"} + + {riskLevel && ( + + ({riskLevel}) + + )} + + + + + + + + + + + + + + + + + {categoryResult.criteria.length > 0 && ( + + + Criteria Summary + + + + + + )} + + ); +}; diff --git a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx index df02407f6..a58cf58dd 100644 --- a/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx +++ b/client/src/app/pages/sbom-group-details/components/assessment-wizard.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import axios, { type AxiosRequestConfig } from "axios"; import { @@ -15,11 +16,14 @@ import { import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; import { FORM_DATA_FILE_KEY } from "@app/Constants"; +import type { RiskAssessmentResults } from "@app/queries/risk-assessments"; +import { RiskAssessmentsQueryKey } from "@app/queries/risk-assessments"; import { ASSESSMENT_CATEGORIES, AssessmentCategoryStep, } from "./assessment-category-step"; +import { AssessmentCategoryResults } from "./assessment-results"; const RISK_ASSESSMENTS = "/api/v2/risk-assessment"; @@ -42,17 +46,30 @@ const uploadRiskAssessmentDocument = ( interface AssessmentWizardProps { riskAssessmentId: string; + /** Per-category results data from the API. */ + results?: RiskAssessmentResults; + /** Callback to delete the current assessment and create a fresh one. */ + onStartNewAssessment: () => void; } export const AssessmentWizard: React.FC = ({ riskAssessmentId, + results, + onStartNewAssessment, }) => { + const queryClient = useQueryClient(); const [activeStep, setActiveStep] = React.useState(0); - const [completedSteps, setCompletedSteps] = React.useState>( + const [uploadedSteps, setUploadedSteps] = React.useState>( new Set(), ); + const categoryResultMap = new Map( + results?.categories.map((cat) => [cat.category, cat]) ?? [], + ); + const currentCategory = ASSESSMENT_CATEGORIES[activeStep]; + const currentCategoryResult = categoryResultMap.get(currentCategory.key); + const isCategoryProcessed = currentCategoryResult?.processed ?? false; const handleBack = () => { setActiveStep((prev) => Math.max(0, prev - 1)); @@ -69,42 +86,87 @@ export const AssessmentWizard: React.FC = ({ - - uploadRiskAssessmentDocument( - riskAssessmentId, - currentCategory.key, - formData, - config, - ) - } - onUploadSuccess={() => { - setCompletedSteps((prev) => new Set(prev).add(activeStep)); - }} - /> + {isCategoryProcessed && currentCategoryResult ? ( + + ) : ( + + uploadRiskAssessmentDocument( + riskAssessmentId, + currentCategory.key, + formData, + config, + ) + } + onUploadSuccess={async () => { + setUploadedSteps((prev) => new Set(prev).add(activeStep)); + await queryClient.invalidateQueries({ + queryKey: [RiskAssessmentsQueryKey], + }); + }} + /> + )} diff --git a/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx new file mode 100644 index 000000000..47eeccd31 --- /dev/null +++ b/client/src/app/pages/sbom-group-details/components/criteria-summary-table.tsx @@ -0,0 +1,98 @@ +import type React from "react"; + +import { Flex, FlexItem, Label } from "@patternfly/react-core"; +import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; +import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon"; +import ExclamationTriangleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; + +import type { CriterionResult } from "@app/queries/risk-assessments"; + +interface CriteriaSummaryTableProps { + criteria: CriterionResult[]; +} + +/** Format a snake_case criterion key into a readable label. */ +const formatCriterionLabel = (key: string) => { + return key.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()); +}; + +/** Map a completeness string to a PatternFly Label color. */ +const completenessColor = (value: string) => { + switch (value) { + case "complete": + return "green"; + case "partial": + return "blue"; + case "missing": + return "yellow"; + default: + return "grey"; + } +}; + +/** Render a risk level with an appropriate status icon. */ +const RiskLevelDisplay: React.FC<{ value: string }> = ({ value }) => { + const lower = value.toLowerCase(); + + let icon: React.ReactNode; + if (lower === "very high" || lower === "high") { + icon = ( + + ); + } else if (lower === "moderate") { + icon = ( + + ); + } else { + icon = ( + + ); + } + + return ( + + {icon} + {formatCriterionLabel(value)} + + ); +}; + +export const CriteriaSummaryTable: React.FC = ({ + criteria, +}) => { + return ( + + + + + + + + + + + {criteria.map((item) => ( + + + + + + + ))} + +
CriterionCompletenessRisk LevelScore
+ {formatCriterionLabel(item.criterion)} + + + + + {item.score}
+ ); +}; diff --git a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx index 01910b7e5..f97bcb0e9 100644 --- a/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx +++ b/client/src/app/pages/sbom-group-details/product-risk-assessment.tsx @@ -1,16 +1,88 @@ import type React from "react"; -import { Content, Stack, StackItem } from "@patternfly/react-core"; +import { + Button, + Content, + Spinner, + Stack, + StackItem, +} from "@patternfly/react-core"; + +import { StateError } from "@app/components/StateError"; +import { StateNoData } from "@app/components/StateNoData"; +import { + useCreateRiskAssessmentMutation, + useDeleteRiskAssessmentMutation, + useFetchRiskAssessmentResults, + useFetchRiskAssessmentsByGroup, +} from "@app/queries/risk-assessments"; import { AssessmentWizard } from "./components/assessment-wizard"; interface ProductRiskAssessmentProps { - riskAssessmentId: string; + groupId: string; } export const ProductRiskAssessment: React.FC = ({ - riskAssessmentId, + groupId, }) => { + const { assessments, isFetching, fetchError } = + useFetchRiskAssessmentsByGroup(groupId); + + const latestAssessment = + assessments.length > 0 ? assessments[assessments.length - 1] : undefined; + + const { results } = useFetchRiskAssessmentResults(latestAssessment?.id); + + const deleteMutation = useDeleteRiskAssessmentMutation( + () => {}, + () => {}, + ); + const createMutation = useCreateRiskAssessmentMutation( + () => {}, + () => {}, + ); + + const handleStartNewAssessment = async () => { + if (latestAssessment) { + await deleteMutation.mutateAsync(latestAssessment.id); + } + createMutation.mutate(groupId); + }; + + const handleCreateAssessment = () => { + createMutation.mutate(groupId); + }; + + if (isFetching) { + return ; + } + + if (fetchError) { + return ; + } + + if (!latestAssessment) { + return ( + + + + The NIST 800-30 Product Risk Assessment (PRA) is a rigorous + methodology used to assess and manage product risks. + + + + + + + + + + ); + } + return ( @@ -20,7 +92,11 @@ export const ProductRiskAssessment: React.FC = ({ - + ); diff --git a/client/src/app/pages/sbom-group-details/sbom-group-details.tsx b/client/src/app/pages/sbom-group-details/sbom-group-details.tsx index 7240d049e..4ae5af5b6 100644 --- a/client/src/app/pages/sbom-group-details/sbom-group-details.tsx +++ b/client/src/app/pages/sbom-group-details/sbom-group-details.tsx @@ -113,7 +113,7 @@ export const SBOMGroupDetails: React.FC = () => { ref={riskAssessmentTabRef} aria-label="Product Risk Assessment" > - + diff --git a/client/src/app/queries/risk-assessments.ts b/client/src/app/queries/risk-assessments.ts new file mode 100644 index 000000000..40c415860 --- /dev/null +++ b/client/src/app/queries/risk-assessments.ts @@ -0,0 +1,164 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import axios, { type AxiosError } from "axios"; + +const RISK_ASSESSMENTS = "/api/v2/risk-assessment"; + +export const RiskAssessmentsQueryKey = "risk-assessments"; + +/** A risk assessment associated with a group. */ +export interface RiskAssessment { + id: string; + groupId: string; + status: string; + overallScore?: number; + createdAt: string; + updatedAt: string; +} + +/** Per-criterion evaluation within a category. */ +export interface CriterionResult { + id: string; + criterion: string; + completeness: string; + riskLevel: string; + score: number; + details?: unknown; +} + +/** Results for a single assessment category (e.g. a document). */ +export interface CategoryResult { + category: string; + documentId: string; + processed: boolean; + criteria: CriterionResult[]; +} + +/** Aggregated score for a single category. */ +export interface CategoryScore { + category: string; + score: number; + weight: number; + weightedScore: number; + riskLevel: string; + criteriaCount: number; +} + +/** Overall scoring summary across all categories. */ +export interface OverallScore { + score: number; + riskLevel: string; + missingCategories: string[]; +} + +/** Aggregated scoring result with overall and per-category breakdowns. */ +export interface ScoringResult { + overall: OverallScore; + categories: CategoryScore[]; +} + +/** Full results for a completed risk assessment. */ +export interface RiskAssessmentResults { + assessmentId: string; + overallScore?: number; + categories: CategoryResult[]; + scoring?: ScoringResult; +} + +/** Fetch all risk assessments for a given group. */ +export const useFetchRiskAssessmentsByGroup = (groupId?: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: [RiskAssessmentsQueryKey, "group", groupId], + queryFn: () => + axios.get(`${RISK_ASSESSMENTS}/group/${groupId}`), + enabled: !!groupId, + }); + + return { + assessments: data?.data ?? [], + isFetching: isLoading, + fetchError: error as AxiosError | null, + }; +}; + +/** Fetch detailed results for a specific risk assessment. */ +export const useFetchRiskAssessmentResults = (id?: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: [RiskAssessmentsQueryKey, id, "results"], + queryFn: () => + axios.get(`${RISK_ASSESSMENTS}/${id}/results`), + enabled: !!id, + }); + + return { + results: data?.data, + isFetching: isLoading, + fetchError: error as AxiosError | null, + }; +}; + +/** Create a new risk assessment for a group. */ +export const useCreateRiskAssessmentMutation = ( + onSuccess: (response: { id: string }) => void, + onError: (err: AxiosError) => void, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (groupId: string) => { + const response = await axios.post<{ id: string }>(RISK_ASSESSMENTS, { + groupId, + }); + return response.data; + }, + onSuccess: async (response) => { + await queryClient.invalidateQueries({ + queryKey: [RiskAssessmentsQueryKey], + }); + onSuccess(response); + }, + onError: onError, + }); +}; + +/** Delete a risk assessment by ID. */ +export const useDeleteRiskAssessmentMutation = ( + onSuccess: () => void, + onError: (err: AxiosError) => void, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + await axios.delete(`${RISK_ASSESSMENTS}/${id}`); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [RiskAssessmentsQueryKey], + }); + onSuccess(); + }, + onError: onError, + }); +}; + +/** Download the generated PDF report for a risk assessment. */ +export const useDownloadAssessmentReport = (assessmentId?: string) => { + const download = async () => { + if (!assessmentId) return; + const response = await axios.get( + `${RISK_ASSESSMENTS}/${assessmentId}/report`, + { + responseType: "blob", + headers: { Accept: "application/pdf" }, + }, + ); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `risk-assessment-${assessmentId}.pdf`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }; + + return { download }; +};