diff --git a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java index 5421da72f..0021ab641 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java @@ -1,14 +1,14 @@ /******************************************************************************** - * Copyright (c) 2026 Contributors to the Eclipse Foundation + * Copyright (c) 2026 Contributors to the Eclipse Foundation * - * See the NOTICE file(s) distributed with this work for additional + * 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 + * 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 + * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ package org.eclipse.openvsx.admin; @@ -20,7 +20,6 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; @@ -40,6 +39,7 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.data.domain.PageRequest; @@ -356,10 +356,10 @@ public ResponseEntity getAllScans( var sort = createSort(sortBy, ascending); var pageNumber = offset / Math.max(size, 1); var pageable = PageRequest.of(pageNumber, size, sort); - + // Automatically include scans with errored check results when filtering by ERRORED status var includeCheckErrors = statusFilter.contains(ScanStatus.ERRORED); - + var page = repositories.findScansFullyFiltered( statusFilter.isEmpty() ? null : statusFilter, normalizedNamespace.isEmpty() ? null : normalizedNamespace, @@ -434,7 +434,7 @@ public boolean hasFilter() { /** * Parse admin decision filter into structured values for DB query. * Returns null if no filter is specified (show all). - * + * * API values: allowed, blocked, needs-review * DB values: ALLOWED, BLOCKED, (no record = needs-review) */ @@ -442,17 +442,17 @@ private AdminDecisionFilterValues parseAdminDecisionFilter(List adminDec if (adminDecision == null || adminDecision.isEmpty()) { return null; } - + var values = adminDecision.stream() .filter(v -> v != null && !v.isBlank()) .map(String::trim) .map(String::toLowerCase) .collect(Collectors.toSet()); - + if (values.isEmpty()) { return null; } - + return new AdminDecisionFilterValues( values.contains("allowed"), values.contains("blocked"), @@ -485,14 +485,14 @@ private String toDbSortField(String sortBy) { private Sort createSort(String sortBy, boolean ascending) { var direction = ascending ? Sort.Direction.ASC : Sort.Direction.DESC; var normalizedSortBy = sortBy == null ? "scanendtime" : sortBy.toLowerCase(Locale.ROOT); - + if ("scanendtime".equals(normalizedSortBy)) { var completedAtOrder = new Sort.Order(direction, "completed_at") .with(Sort.NullHandling.NULLS_FIRST); var startedAtOrder = new Sort.Order(direction, "started_at"); return Sort.by(completedAtOrder, startedAtOrder); } - + var dbSortField = toDbSortField(sortBy); return Sort.by(direction, dbSortField); } @@ -624,12 +624,12 @@ public ResponseEntity retryFailedScannerJobs( * Make security decisions for one or more quarantined scans. * Only valid for scans with QUARANTINED status. * Pass a single scanId for individual decisions, or multiple scanIds for bulk operations. - * + * * When a scan is allowed: * - The extension is automatically activated * - The scan status is updated to PASSED * - File decisions are created to add enforced threat files to allow list - * + * * When a scan is blocked: * - The extension remains inactive * - File decisions are created to add enforced threat files to block list @@ -666,39 +666,39 @@ public ResponseEntity makeScanDecisions( if (request.getDecision() == null || request.getDecision().isBlank()) { throw new ErrorResultException("Decision is required", HttpStatus.BAD_REQUEST); } - + var decisionValue = parseDecision(request.getDecision()); - + var results = new java.util.ArrayList(); int successful = 0; int failed = 0; - + for (var scanIdStr : request.getScanIds()) { try { var scanId = Long.parseLong(scanIdStr); var scan = repositories.findExtensionScan(scanId); - + if (scan == null) { results.add(ScanDecisionResultJson.failure(scanIdStr, "Scan not found")); failed++; continue; } - + if (scan.getStatus() != ScanStatus.QUARANTINED) { - results.add(ScanDecisionResultJson.failure(scanIdStr, + results.add(ScanDecisionResultJson.failure(scanIdStr, "Scan not in quarantined status: " + formatScanStatus(scan.getStatus()))); failed++; continue; } - + var existingDecision = repositories.findAdminScanDecisionByScanId(scanId); if (existingDecision != null) { - results.add(ScanDecisionResultJson.failure(scanIdStr, + results.add(ScanDecisionResultJson.failure(scanIdStr, "Decision already exists: " + existingDecision.getDecision())); failed++; continue; } - + AdminScanDecision decision; if (AdminScanDecision.ALLOWED.equals(decisionValue)) { decision = AdminScanDecision.allowed(scan, adminUser); @@ -706,24 +706,24 @@ public ResponseEntity makeScanDecisions( decision = AdminScanDecision.blocked(scan, adminUser); } repositories.saveAdminScanDecision(decision); - + var threats = repositories.findExtensionThreats(scan).toList(); for (var threat : threats) { if (threat.isEnforced() && threat.getFileHash() != null) { createOrUpdateFileDecision(threat, scan, decisionValue, adminUser); } } - + // If allowed, activate the extension boolean activated = false; if (AdminScanDecision.ALLOWED.equals(decisionValue)) { activated = completionService.adminAllowScan(scan); } - + // Log the admin decision to /admin/log var logMessage = formatDecisionLogMessage(scan, decisionValue, threats.size(), activated); logs.logAction(adminUser, ResultJson.success(logMessage)); - + results.add(ScanDecisionResultJson.success(scanIdStr)); successful++; } catch (NumberFormatException e) { @@ -734,13 +734,13 @@ public ResponseEntity makeScanDecisions( failed++; } } - + var response = new ScanDecisionResponseJson(); response.setProcessed(request.getScanIds().size()); response.setSuccessful(successful); response.setFailed(failed); response.setResults(results); - + return ResponseEntity.ok(response); } catch (ErrorResultException exc) { return exc.toResponseEntity(ScanDecisionResponseJson.class); @@ -764,11 +764,11 @@ private String parseDecision(String decision) { */ private String formatDecisionLogMessage(ExtensionScan scan, String decisionValue, int threatCount, boolean activated) { var action = AdminScanDecision.ALLOWED.equals(decisionValue) ? "Allowed" : "Blocked"; - var extensionId = String.format("%s.%s v%s", - scan.getNamespaceName(), - scan.getExtensionName(), + var extensionId = String.format("%s.%s v%s", + scan.getNamespaceName(), + scan.getExtensionName(), scan.getExtensionVersion()); - + var details = new java.util.ArrayList(); if (threatCount > 0) { details.add(String.format("%d threat%s reviewed", threatCount, threatCount == 1 ? "" : "s")); @@ -776,10 +776,10 @@ private String formatDecisionLogMessage(ExtensionScan scan, String decisionValue if (AdminScanDecision.ALLOWED.equals(decisionValue)) { details.add(activated ? "extension activated" : "activation failed"); } - + var detailsStr = details.isEmpty() ? "" : " (" + String.join(", ", details) + ")"; - - return String.format("%s scan #%d for extension %s%s", + + return String.format("%s scan #%d for extension %s%s", action, scan.getId(), extensionId, detailsStr); } @@ -794,12 +794,12 @@ private void createOrUpdateFileDecision( UserData adminUser ) { var fileHash = threat.getFileHash(); - + // Map scan decision to file decision var fileDecisionValue = AdminScanDecision.ALLOWED.equals(decisionValue) ? FileDecision.ALLOWED : FileDecision.BLOCKED; - + var existingDecision = repositories.findFileDecisionByHash(fileHash); if (existingDecision != null) { existingDecision.setDecision(fileDecisionValue); @@ -808,7 +808,7 @@ private void createOrUpdateFileDecision( repositories.saveFileDecision(existingDecision); return; } - + // Create new file decision with context from threat and scan var fileDecision = new FileDecision(); fileDecision.setFileHash(fileHash); @@ -898,11 +898,23 @@ private ScanResultJson toScanResultJson(ExtensionScan scan) { json.setAdminDecision(toAdminDecisionJson(adminDecision)); } + var scannerJobs = scanJobRepository.findByScanId(String.valueOf(scan.getId())); + if (!scannerJobs.isEmpty()) { + // include all non-terminal scanner jobs + var scannerJobJsons = scannerJobs.stream() + .filter(job -> !job.getStatus().isTerminal()) + .map(this::toScannerJobJson) + .collect(Collectors.toList()); + json.setScannerJobs(scannerJobJsons); + } + // Include all check results for audit trail var checkResults = repositories.findScanCheckResultsByScanId(scan.getId()); if (!checkResults.isEmpty()) { + var scannerJobsById = scannerJobs.stream().collect(Collectors.toMap(ScannerJob::getId, Function.identity())); + var checkResultJsons = checkResults.stream() - .map(this::toCheckResultJson) + .map(r -> toCheckResultJson(r, scannerJobsById.get(r.getScannerJobId()))) .collect(Collectors.toList()); json.setCheckResults(checkResultJsons); } @@ -910,10 +922,44 @@ private ScanResultJson toScanResultJson(ExtensionScan scan) { return json; } + /** + * Converts a ScannerJob entity to a ScannerJobJson DTO. + */ + private ScannerJobJson toScannerJobJson(ScannerJob job) { + var json = new ScannerJobJson(); + json.setId(String.valueOf(job.getId())); + json.setScannerType(job.getScannerType()); + json.setStatus(job.getStatus().name()); + json.setCreatedAt(TimeUtil.toUTCString(job.getCreatedAt())); + json.setUpdatedAt(TimeUtil.toUTCString(job.getUpdatedAt())); + json.setErrorMessage(job.getErrorMessage()); + json.setExternalUrl(buildExternalScannerUrl(job)); + return json; + } + + /** + * Build the external dashboard URL for a scanner job, if its scanner configures + * an external-url-template and the job has an external id. + */ + @Nullable + private String buildExternalScannerUrl(@Nullable ScannerJob job) { + if (job == null) { + return null; + } + if (job.getExternalJobId() == null) { + return null; + } + var scanner = scannerRegistry.getScanner(job.getScannerType()); + if (scanner == null) { + return null; + } + return scanner.buildExternalUrl(job.getExternalJobId()); + } + /** * Converts a ScanCheckResult entity to a CheckResultJson DTO. */ - private CheckResultJson toCheckResultJson(ScanCheckResult checkResult) { + private CheckResultJson toCheckResultJson(ScanCheckResult checkResult, ScannerJob scannerJob) { var json = new CheckResultJson(); json.setCheckType(checkResult.getCheckType()); json.setCategory(checkResult.getCategory().name()); @@ -928,36 +974,11 @@ private CheckResultJson toCheckResultJson(ScanCheckResult checkResult) { json.setSummary(checkResult.getSummary()); json.setErrorMessage(checkResult.getErrorMessage()); json.setRequired(checkResult.getRequired()); - json.setExternalUrl(buildExternalScannerUrl(checkResult)); + json.setExternalUrl(buildExternalScannerUrl(scannerJob)); return json; } - /** - * Look up the ScannerJob for a SCANNER_JOB check result and ask its scanner - * to build a dashboard URL. Returns null for publish checks, jobs without an - * external id, or scanners that don't configure a url template. - */ - @Nullable - private String buildExternalScannerUrl(@Nonnull ScanCheckResult checkResult) { - if (checkResult.getCategory() != ScanCheckResult.CheckCategory.SCANNER_JOB) { - return null; - } - Long jobId = checkResult.getScannerJobId(); - if (jobId == null) { - return null; - } - var job = scanJobRepository.findById(jobId).orElse(null); - if (job == null || job.getExternalJobId() == null) { - return null; - } - var scanner = scannerRegistry.getScanner(job.getScannerType()); - if (scanner == null) { - return null; - } - return scanner.buildExternalUrl(job.getExternalJobId()); - } - /** * Converts an ExtensionThreat entity to a ThreatJson DTO. */ @@ -1139,4 +1160,3 @@ private boolean normalizeSortOrder(String sortOrder) { } } - diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java index 9dbdb6666..c59843a35 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java @@ -85,6 +85,11 @@ public class ScanResultJson extends ResultJson { @Schema(description = "All checks/scans that were executed (pass, fail, or skip)") private List checkResults; + @Schema(description = "Scanner jobs for this scan with their current lifecycle state " + + "(QUEUED, PROCESSING, SUBMITTED, COMPLETE, FAILED, REMOVED). Lets the UI surface " + + "ongoing or queued scanners without inferring their state from checkResults.") + private List scannerJobs; + @Schema(description = "Error message if the scan failed with an error") private String errorMessage; @@ -248,6 +253,14 @@ public void setCheckResults(List checkResults) { this.checkResults = checkResults; } + public List getScannerJobs() { + return scannerJobs; + } + + public void setScannerJobs(List scannerJobs) { + this.scannerJobs = scannerJobs; + } + public String getErrorMessage() { return errorMessage; } diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScannerJobJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScannerJobJson.java new file mode 100644 index 000000000..d38715ac1 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScannerJobJson.java @@ -0,0 +1,113 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; + +/** + * JSON representation of a scanner job, exposing its current lifecycle state. + * Used by the admin dashboard to distinguish ongoing/queued scanner work from + * completed check results (which live on {@link CheckResultJson}). + */ +@Schema( + name = "ScannerJob", + description = "Lifecycle state of a scanner job for an extension scan" +) +@JsonInclude(Include.NON_NULL) +public class ScannerJobJson { + + @Schema(description = "Unique identifier of the scanner job") + private String id; + + @Schema(description = "Identifies the scanner type that runs this job") + private String scannerType; + + @Schema(description = "Current lifecycle status: QUEUED, PROCESSING, SUBMITTED, COMPLETE, FAILED, REMOVED") + private String status; + + @Schema(description = "When the job was created (UTC)") + private String createdAt; + + @Schema(description = "When the job was last updated (UTC)") + private String updatedAt; + + @Schema(description = "Error message if the job failed or was removed") + @Nullable + private String errorMessage; + + @Schema(description = "Deep link to the external scanner's own dashboard for this job. " + + "Only populated for async scanners that configure external-url-template.") + @Nullable + private String externalUrl; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getScannerType() { + return scannerType; + } + + public void setScannerType(String scannerType) { + this.scannerType = scannerType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } + + @Nullable + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(@Nullable String errorMessage) { + this.errorMessage = errorMessage; + } + + @Nullable + public String getExternalUrl() { + return externalUrl; + } + + public void setExternalUrl(@Nullable String externalUrl) { + this.externalUrl = externalUrl; + } +} diff --git a/webui/.gitignore b/webui/.gitignore index f417d252d..c84932e8e 100644 --- a/webui/.gitignore +++ b/webui/.gitignore @@ -17,3 +17,6 @@ yarn-error.log /playwright/.cache/ stats.html package.tgz + +# incremental build cache +*/**/*.tsbuildinfo diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index b0dc6f777..603ec06ed 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -7,6 +7,7 @@ This change log covers only the frontend library (webui) of Open VSX. ### Added - Add support to retry failed scanner jobs in the admin dashboard ([#1832](https://github.com/eclipse-openvsx/openvsx/pull/1832)) +- Display non-terminal scanner jobs in the scan card ([#1836](https://github.com/eclipse-openvsx/openvsx/pull/1836)) ## [v0.20.3] (08/05/2026) diff --git a/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx b/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx index 5beace84a..7fbb41987 100644 --- a/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx +++ b/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx @@ -12,11 +12,11 @@ ********************************************************************************/ import { FC } from 'react'; -import { Box, Typography, Collapse, Chip, Link, Button, CircularProgress } from '@mui/material'; +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 } from '../../../context/scan-admin'; +import { ScanResult, Threat, ValidationFailure, CheckResult, ScannerJob } from '../../../context/scan-admin'; import { ScanDetailCard } from './scan-detail-card'; import { formatDateTime } from '../common'; @@ -41,6 +41,10 @@ interface CheckResultItemProps { checkResult: CheckResult; } +interface ScannerJobItemProps { + job: ScannerJob; +} + /** * A single threat item in the expanded content. */ @@ -227,6 +231,85 @@ const CheckResultItem: FC = ({ checkResult }) => { ); }; +/** + * 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. @@ -243,8 +326,9 @@ export const ScanCardExpandedContent: FC = ({ 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 || hasErrorMessage; + const hasAnyContent = hasThreats || hasValidationFailures || hasCheckResults || hasScannerJobs || hasErrorMessage; return ( @@ -271,12 +355,12 @@ export const ScanCardExpandedContent: FC = ({ )} - {/* Check Results - What scans/checks were run */} - {hasCheckResults && ( - + {/* Scanner Jobs - Lifecycle state of each scanner running for this extension */} + {hasScannerJobs && ( + - Checks Executed + Scanner Jobs {canRetryFailedScannerJobs && ( )} + + {scan.scannerJobs.map((job) => ( + + ))} + + + )} + + {/* Check Results - What scans/checks were run */} + {hasCheckResults && ( + + + Checks Executed + {scan.checkResults.map((result, index) => ( diff --git a/webui/src/components/scan-admin/scan-card/utils.ts b/webui/src/components/scan-admin/scan-card/utils.ts index 163d3767b..9ff7ff76f 100644 --- a/webui/src/components/scan-admin/scan-card/utils.ts +++ b/webui/src/components/scan-admin/scan-card/utils.ts @@ -38,9 +38,7 @@ export const isRunning = (status: ScanResult['status']): boolean => { }; export const hasFailedScannerJobs = (scan: ScanResult): boolean => { - return scan.checkResults?.some( - checkResult => checkResult.category === 'SCANNER_JOB' && checkResult.result === 'ERROR' - ) ?? false; + return scan.scannerJobs?.some(job => job.status === 'FAILED') ?? false; }; /** @@ -191,7 +189,8 @@ export const getStatusBarColor = (status: ScanResult['status'], theme: any) => { export const shouldShowExpandButton = (scan: ScanResult): boolean => { const hasErrorMessage = scan.status === 'ERROR' && !!scan.errorMessage; const hasCheckResults = scan.checkResults && scan.checkResults.length > 0; - return scan.threats.length > 0 || scan.validationFailures.length > 0 || hasCheckResults || hasErrorMessage; + const hasScannerJobs = scan.scannerJobs && scan.scannerJobs.length > 0; + return scan.threats.length > 0 || scan.validationFailures.length > 0 || hasCheckResults || hasScannerJobs || hasErrorMessage; }; export const hasDownload = (scan: ScanResult): boolean => { diff --git a/webui/src/context/scan-admin/scan-api-effects.ts b/webui/src/context/scan-admin/scan-api-effects.ts index 6f7bd2669..fa5b79a7c 100644 --- a/webui/src/context/scan-admin/scan-api-effects.ts +++ b/webui/src/context/scan-admin/scan-api-effects.ts @@ -213,6 +213,15 @@ export const useScansEffect = ( required: result.required ?? null, externalUrl: result.externalUrl || null, })), + scannerJobs: (scan.scannerJobs || []).map((job: any) => ({ + id: job.id, + scannerType: job.scannerType, + status: job.status, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + errorMessage: job.errorMessage || null, + externalUrl: job.externalUrl || null, + })), extensionIcon: scan.extensionIcon, downloadUrl: scan.downloadUrl || null, errorMessage: scan.errorMessage || null, diff --git a/webui/src/context/scan-admin/scan-types.ts b/webui/src/context/scan-admin/scan-types.ts index 1bcb1adb9..4a5f37dbc 100644 --- a/webui/src/context/scan-admin/scan-types.ts +++ b/webui/src/context/scan-admin/scan-types.ts @@ -62,6 +62,19 @@ export interface CheckResult { externalUrl: string | null; } +export type ScannerJobStatus = 'QUEUED' | 'PROCESSING' | 'SUBMITTED' | 'COMPLETE' | 'FAILED' | 'REMOVED'; + +export interface ScannerJob { + id: string; + scannerType: string; + status: ScannerJobStatus; + createdAt: string; + updatedAt: string; + errorMessage: string | null; + /** Deep link to the external scanner's dashboard for this job. Null if not applicable. */ + externalUrl: string | null; +} + export interface ScanResult { id: string; displayName: string; @@ -81,6 +94,7 @@ export interface ScanResult { threats: Threat[]; validationFailures: ValidationFailure[]; checkResults: CheckResult[]; + scannerJobs: ScannerJob[]; extensionIcon?: string; downloadUrl: string | null; errorMessage: string | null;