/******************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * https://www.eclipse.org/legal/epl-2.0 * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ import { FC } from 'react'; import { Box, Typography, Collapse, Chip, Link, Button, CircularProgress, Tooltip } from '@mui/material'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import ReplayIcon from '@mui/icons-material/Replay'; import { useTheme, Theme } from '@mui/material/styles'; import { ScanResult, Threat, ValidationFailure, CheckResult, ScannerJob } from '../../../context/scan-admin'; import { ScanDetailCard } from './scan-detail-card'; import { formatDateTime } from '../common'; interface ScanCardExpandedContentProps { scan: ScanResult; expanded: boolean; canRetryFailedScannerJobs?: boolean; isRetryingFailedScannerJobs?: boolean; onRetryFailedScannerJobs?: () => void; onCollapseComplete?: () => void; } interface ThreatItemProps { threat: Threat; } interface ValidationFailureItemProps { failure: ValidationFailure; } interface CheckResultItemProps { checkResult: CheckResult; } interface ScannerJobItemProps { job: ScannerJob; } /** * A single threat item in the expanded content. */ const ThreatItem: FC = ({ threat }) => { const theme = useTheme(); return ( ); }; /** * A single validation failure item in the expanded content. */ const ValidationFailureItem: FC = ({ failure }) => { const theme = useTheme(); const isUnenforced = !failure.enforcedFlag; return ( ); }; /** * Get color for check result status. */ const getCheckResultColor = (result: CheckResult['result'], theme: Theme) => { switch (result) { case 'PASSED': return { bg: theme.palette.success.dark, text: theme.palette.success.light }; case 'QUARANTINE': // Orange/amber - scanner found enforced threats, recommends quarantine return { bg: theme.palette.quarantined.dark as string, text: theme.palette.quarantined.light as string }; case 'REJECT': // Red - publish check recommends rejection return { bg: theme.palette.rejected.dark as string, text: theme.palette.rejected.light as string }; case 'ERROR': return { bg: theme.palette.errorStatus.dark as string, text: theme.palette.errorStatus.light as string }; default: return { bg: theme.palette.grey[500], text: theme.palette.grey[100] }; } }; /** * Format duration in milliseconds to a readable string. */ const formatDuration = (ms: number | null): string | undefined => { if (ms === null) return undefined; if (ms === 0) return '<1ms'; if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 60000).toFixed(1)}m`; }; /** * A single check result item showing what check was run and its outcome. */ const CheckResultItem: FC = ({ checkResult }) => { const theme = useTheme(); const colors = getCheckResultColor(checkResult.result, theme); const isPassed = checkResult.result === 'PASSED'; const isError = checkResult.result === 'ERROR'; // Non-required errors get striped styling to indicate they didn't block publishing const isOptionalError = isError && checkResult.required === false; // Scanner rows often carry long verdict text while publish checks just show // short statuses like "No issues found" const isScannerJob = checkResult.category === 'SCANNER_JOB'; const showSummaryInMiddle = isScannerJob && !!checkResult.summary; return ( {checkResult.checkType} {showSummaryInMiddle && ( {checkResult.summary} )} {!showSummaryInMiddle && checkResult.summary && ( {checkResult.summary} )} {checkResult.externalUrl && ( e.stopPropagation()} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25, fontSize: '0.7rem', fontWeight: 500, whiteSpace: 'nowrap', }} > View in scanner )} {checkResult.durationMs !== null && ( {formatDuration(checkResult.durationMs)} )} ); }; /** * Get color for scanner job lifecycle status. */ const getScannerJobColor = (status: ScannerJob['status'], theme: Theme) => { switch (status) { case 'COMPLETE': return { bg: theme.palette.success.dark, text: theme.palette.success.light }; case 'FAILED': return { bg: theme.palette.errorStatus.dark as string, text: theme.palette.errorStatus.light as string }; case 'REMOVED': return { bg: theme.palette.grey[700], text: theme.palette.grey[100] }; case 'QUEUED': return { bg: theme.palette.info.dark, text: theme.palette.info.light }; case 'PROCESSING': case 'SUBMITTED': return { bg: theme.palette.warning.dark, text: theme.palette.warning.light }; default: return { bg: theme.palette.info.dark, text: theme.palette.info.light }; } }; const isRunningScannerJob = (status: ScannerJob['status']): boolean => status === 'PROCESSING' || status === 'SUBMITTED'; /** * A single stackable scanner job pill: scanner type + current lifecycle state. * Active jobs (QUEUED/PROCESSING/SUBMITTED) pulse so it's obvious they're still * in flight. Clickable when the scanner exposes an external dashboard URL. * Error message (if any) is surfaced via tooltip to keep the pill compact. */ const ScannerJobItem: FC = ({ job }) => { const theme = useTheme(); const colors = getScannerJobColor(job.status, theme); const isRunning = isRunningScannerJob(job.status); const chip = ( {job.scannerType} ยท {job.status} {job.externalUrl && ( )} } size='small' clickable={!!job.externalUrl} component={job.externalUrl ? 'a' : 'div'} {...(job.externalUrl && { href: job.externalUrl, target: '_blank', rel: 'noopener noreferrer', onClick: (e: React.MouseEvent) => e.stopPropagation(), })} sx={{ bgcolor: colors.bg, color: colors.text, fontSize: '0.75rem', height: 26, px: 0.5, ...(isRunning && { animation: 'scanner-job-pulse 1.6s ease-in-out infinite', '@keyframes scanner-job-pulse': { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.45 }, }, }), '&:hover': job.externalUrl ? { bgcolor: colors.bg, filter: 'brightness(1.1)' } : undefined, }} /> ); return job.errorMessage ? {chip} : chip; }; /** * The expanded content section showing threats, validation failures, and check results. * Each item's enforcedFlag controls its individual striping effect. */ export const ScanCardExpandedContent: FC = ({ scan, expanded, canRetryFailedScannerJobs, isRetryingFailedScannerJobs, onRetryFailedScannerJobs, onCollapseComplete, }) => { const theme = useTheme(); const hasThreats = scan.threats.length > 0; const hasValidationFailures = scan.validationFailures.length > 0; const hasCheckResults = scan.checkResults && scan.checkResults.length > 0; const hasScannerJobs = scan.scannerJobs && scan.scannerJobs.length > 0; const hasErrorMessage = scan.status === 'ERROR' && scan.errorMessage; const hasAnyContent = hasThreats || hasValidationFailures || hasCheckResults || hasScannerJobs || hasErrorMessage; return ( {/* Error Message */} {hasErrorMessage && ( )} {/* Scanner Jobs - Lifecycle state of each scanner running for this extension */} {hasScannerJobs && ( Scanner Jobs {canRetryFailedScannerJobs && ( )} {scan.scannerJobs.map((job) => ( ))} )} {/* Check Results - What scans/checks were run */} {hasCheckResults && ( Checks Executed {scan.checkResults.map((result, index) => ( ))} )} {/* Threats */} {hasThreats && ( Threats Detected {scan.threats.map((threat, index) => ( ))} )} {/* Validation Failures */} {hasValidationFailures && ( Validation Failures {scan.validationFailures.map((failure, index) => ( ))} )} ); };