diff --git a/ui/changelog.d/scan-findings-utc-day.fixed.md b/ui/changelog.d/scan-findings-utc-day.fixed.md new file mode 100644 index 0000000000..43ad7cc94f --- /dev/null +++ b/ui/changelog.d/scan-findings-utc-day.fixed.md @@ -0,0 +1 @@ +`View Findings` on the Scans page no longer opens an empty list for users outside the UTC timezone diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx index b01550b0e0..07ea83ba94 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx @@ -44,11 +44,6 @@ vi.mock("@/lib/helper", () => ({ downloadScanZip: downloadScanZipMock, })); -vi.mock("@/lib/date-utils", () => ({ - toLocalDateString: (value: string | null | undefined) => - value ? "2026-01-01" : undefined, -})); - vi.mock("@/components/scans/edit-alias-modal", () => ({ EditAliasModal: ({ open, @@ -314,13 +309,14 @@ describe("ScanJobsRowActions", () => { ).not.toBeInTheDocument(); }); - it("links completed scans to filtered findings", async () => { + it("links completed scans to filtered findings without a browser-local date", async () => { // Given const user = userEvent.setup(); render( { // Then expect(pushMock).toHaveBeenCalledWith( - "/findings?filter[scan__in]=scan-1&filter[inserted_at]=2026-01-01&filter[status__in]=FAIL", + "/findings?filter[scan__in]=scan-1&filter[status__in]=FAIL", ); }); + it("disables View Findings when the completed scan has no completion timestamp", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: /open actions menu/i }), + ); + const viewFindings = screen.getByRole("menuitem", { + name: /view findings/i, + }); + await user.click(viewFindings); + + // Then + expect(viewFindings).toHaveAttribute("aria-disabled", "true"); + expect(pushMock).not.toHaveBeenCalled(); + }); + it("triggers downloadScanZip with the scan id when downloading reports", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/scans/table/scan-jobs-row-actions.tsx b/ui/components/scans/table/scan-jobs-row-actions.tsx index edc7503ba9..fc8b67ff52 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.tsx @@ -30,7 +30,6 @@ import { ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url"; -import { toLocalDateString } from "@/lib/date-utils"; import { downloadScanZip } from "@/lib/helper"; import { getScanScheduleCapability } from "@/lib/schedules"; import { isCloud } from "@/lib/shared/env"; @@ -80,7 +79,9 @@ export function ScanJobsRowActions({ const isCompleted = scanState === "completed"; const isFailed = scanState === "failed"; const taskId = scan.relationships.task.data?.id; - const scanDate = toLocalDateString(scan.attributes.completed_at); + // The findings page bounds the UTC day range with completed_at; without it the + // range collapses to the start day and can miss later findings. + const hasCompletedAt = Boolean(scan.attributes.completed_at); const providerId = scan.relationships.provider.data?.id; const scheduleProvider: ScanScheduleProvider | undefined = providerId ? { @@ -92,9 +93,9 @@ export function ScanJobsRowActions({ : undefined; const openFindings = () => { - if (!isCompleted || !scanDate) return; + if (!isCompleted || !hasCompletedAt) return; router.push( - `/findings?filter[scan__in]=${scan.id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL`, + `/findings?filter[scan__in]=${scan.id}&filter[status__in]=FAIL`, ); }; @@ -202,7 +203,7 @@ export function ScanJobsRowActions({ icon={} label="View Findings" onSelect={openFindings} - disabled={!isCompleted || !scanDate} + disabled={!isCompleted || !hasCompletedAt} /> } diff --git a/ui/lib/findings-scan-filters.test.ts b/ui/lib/findings-scan-filters.test.ts index a1e4d4a4e0..3788fd3707 100644 --- a/ui/lib/findings-scan-filters.test.ts +++ b/ui/lib/findings-scan-filters.test.ts @@ -87,6 +87,120 @@ describe("resolveFindingScanDateFilters", () => { }); }); + it("keeps a single day when the scan starts and completes on the same UTC day", async () => { + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[scan__in]": "scan-1", + }, + scans: [ + { + id: "scan-1", + attributes: { + started_at: "2026-04-07T09:40:00Z", + completed_at: "2026-04-07T10:00:00Z", + }, + }, + ], + loadScan: vi.fn(), + }); + + expect(result).toEqual({ + "filter[scan__in]": "scan-1", + "filter[inserted_at]": "2026-04-07", + }); + }); + + it("covers both UTC days for a scan that crosses midnight while inserting findings", async () => { + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[scan__in]": "scan-1", + }, + scans: [ + { + id: "scan-1", + attributes: { + started_at: "2026-04-06T23:40:00Z", + completed_at: "2026-04-07T00:15:00Z", + }, + }, + ], + loadScan: vi.fn(), + }); + + expect(result).toEqual({ + "filter[scan__in]": "scan-1", + "filter[inserted_at__gte]": "2026-04-06", + "filter[inserted_at__lte]": "2026-04-07", + }); + }); + + it("derives the day from started_at when the scan has not completed yet", async () => { + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[scan__in]": "scan-1", + }, + scans: [ + { + id: "scan-1", + attributes: { + started_at: "2026-04-07T09:40:00Z", + }, + }, + ], + loadScan: vi.fn(), + }); + + expect(result).toEqual({ + "filter[scan__in]": "scan-1", + "filter[inserted_at]": "2026-04-07", + }); + }); + + it("leaves the filters untouched when the scan exposes no timestamps", async () => { + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[scan__in]": "scan-1", + }, + scans: [{ id: "scan-1", attributes: {} }], + loadScan: vi.fn(), + }); + + expect(result).toEqual({ + "filter[scan__in]": "scan-1", + }); + }); + + it("spans every selected scan when several scans are filtered at once", async () => { + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[scan__in]": "scan-1,scan-2", + }, + scans: [ + { + id: "scan-1", + attributes: { + started_at: "2026-04-05T23:50:00Z", + completed_at: "2026-04-06T00:10:00Z", + }, + }, + { + id: "scan-2", + attributes: { + started_at: "2026-04-07T08:00:00Z", + completed_at: "2026-04-07T08:30:00Z", + }, + }, + ], + loadScan: vi.fn(), + }); + + expect(result).toEqual({ + "filter[scan__in]": "scan-1,scan-2", + "filter[inserted_at__gte]": "2026-04-05", + "filter[inserted_at__lte]": "2026-04-07", + }); + }); + it("does not override an explicit inserted_at filter already chosen in the frontend", async () => { const result = await resolveFindingScanDateFilters({ filters: { diff --git a/ui/lib/findings-scan-filters.ts b/ui/lib/findings-scan-filters.ts index a5984cb141..cc56149f5a 100644 --- a/ui/lib/findings-scan-filters.ts +++ b/ui/lib/findings-scan-filters.ts @@ -1,10 +1,9 @@ interface ScanDateSource { id: string; attributes?: { - // Findings are persisted when the scan finishes, so their `inserted_at` - // aligns with the scan's `completed_at` — not the scan's `inserted_at` - // (which is when the scan row was first created and can fall on a - // different UTC day for scans that cross midnight). + // Findings are written throughout the run, so the range spans both + // timestamps, not the scan's `inserted_at` (when the row was created). + started_at?: string; completed_at?: string; }; } @@ -38,10 +37,10 @@ function hasInsertedAtFilter(filters: Record): boolean { } export function buildFindingScanDateFilters( - scanCompletedAtValues: string[], + scanDateTimes: string[], ): Record { const dates = Array.from( - new Set(scanCompletedAtValues.map(formatScanDate).filter(Boolean)), + new Set(scanDateTimes.map(formatScanDate).filter(Boolean)), ).sort() as string[]; if (dates.length === 0) { @@ -86,11 +85,14 @@ export async function resolveFindingScanDateFilters({ }); } - const scanCompletedAtValues = scanIds - .map((scanId) => scansById.get(scanId)?.attributes?.completed_at) - .filter((completedAt): completedAt is string => Boolean(completedAt)); + const scanDateTimes = scanIds + .flatMap((scanId) => { + const attributes = scansById.get(scanId)?.attributes; + return [attributes?.started_at, attributes?.completed_at]; + }) + .filter((dateTime): dateTime is string => Boolean(dateTime)); - const dateFilters = buildFindingScanDateFilters(scanCompletedAtValues); + const dateFilters = buildFindingScanDateFilters(scanDateTimes); if (Object.keys(dateFilters).length === 0) { return filters;