diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 6b325010d2..e2acccc0ce 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.23.0] (Prowler UNRELEASED) +### 🚀 Added + +- Findings grouped view with drill-down table showing resources per check, resource detail drawer, infinite scroll pagination, and bulk mute support [(#10425)](https://github.com/prowler-cloud/prowler/pull/10425) + ### 🔄 Changed - Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445) @@ -31,6 +35,14 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397) + +--- + +## [1.21.0] (Prowler v5.21.0) + +### 🚀 Added + - Skill system to Lighthouse AI [(#10322)](https://github.com/prowler-cloud/prowler/pull/10322) - Skill for creating custom queries on Attack Paths [(#10323)](https://github.com/prowler-cloud/prowler/pull/10323) diff --git a/ui/actions/attack-paths/scans.adapter.ts b/ui/actions/attack-paths/scans.adapter.ts index a8236241a3..e6ef4168e2 100644 --- a/ui/actions/attack-paths/scans.adapter.ts +++ b/ui/actions/attack-paths/scans.adapter.ts @@ -1,3 +1,4 @@ +import { formatDuration } from "@/lib/date-utils"; import { MetaDataProps } from "@/types"; import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths"; @@ -61,18 +62,6 @@ export function adaptAttackPathScansResponse( return { data: enrichedData, metadata }; } -/** - * Format duration in seconds to human-readable format - * - * @param seconds - Duration in seconds - * @returns Formatted duration string (e.g., "2m 30s") - */ -function formatDuration(seconds: number): string { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds}s`; -} - /** * Check if a scan is recent (completed within last 24 hours) * diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts new file mode 100644 index 0000000000..36d14cdfb9 --- /dev/null +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -0,0 +1,140 @@ +import { + FindingGroupRow, + FindingResourceRow, + FINDINGS_ROW_TYPE, + FindingStatus, + ProviderType, + Severity, +} from "@/types"; + +/** + * API response shape for a finding group (JSON:API). + * Each group represents a unique check_id with aggregated counts. + * + * Fields come from FindingGroupSerializer which aggregates + * FindingGroupDailySummary rows by check_id. + */ +interface FindingGroupAttributes { + check_id: string; + check_title: string | null; + check_description: string | null; + severity: string; + status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase) + impacted_providers: string[]; + resources_total: number; + resources_fail: number; + pass_count: number; + fail_count: number; + muted_count: number; + new_count: number; + changed_count: number; + first_seen_at: string | null; + last_seen_at: string | null; + failing_since: string | null; +} + +interface FindingGroupApiItem { + type: "finding-groups"; + id: string; + attributes: FindingGroupAttributes; +} + +/** + * Transforms the API response for finding groups into FindingGroupRow[]. + */ +export function adaptFindingGroupsResponse( + apiResponse: any, +): FindingGroupRow[] { + if (!apiResponse?.data || !Array.isArray(apiResponse.data)) { + return []; + } + + return apiResponse.data.map((item: FindingGroupApiItem) => ({ + id: item.id, + rowType: FINDINGS_ROW_TYPE.GROUP, + checkId: item.attributes.check_id, + checkTitle: item.attributes.check_title || item.attributes.check_id, + severity: item.attributes.severity as Severity, + status: item.attributes.status as FindingStatus, + resourcesTotal: item.attributes.resources_total, + resourcesFail: item.attributes.resources_fail, + newCount: item.attributes.new_count, + changedCount: item.attributes.changed_count, + mutedCount: item.attributes.muted_count, + providers: (item.attributes.impacted_providers || []) as ProviderType[], + updatedAt: item.attributes.last_seen_at || "", + })); +} + +/** + * API response shape for a finding group resource (drill-down). + * Endpoint: /finding-groups/{check_id}/resources + * + * Each item has nested `resource` and `provider` objects in attributes + * (NOT JSON:API included — it's a custom serializer). + */ +interface ResourceInfo { + uid: string; + name: string; + service: string; + region: string; + type: string; + resource_group: string; +} + +interface ProviderInfo { + type: string; + uid: string; + alias: string; +} + +interface FindingGroupResourceAttributes { + resource: ResourceInfo; + provider: ProviderInfo; + status: string; + severity: string; + first_seen_at: string | null; + last_seen_at: string | null; + muted_reason?: string | null; +} + +interface FindingGroupResourceApiItem { + type: "finding-group-resources"; + id: string; + attributes: FindingGroupResourceAttributes; +} + +/** + * Transforms the API response for finding group resources (drill-down) + * into FindingResourceRow[]. + */ +export function adaptFindingGroupResourcesResponse( + apiResponse: any, + checkId: string, +): FindingResourceRow[] { + if (!apiResponse?.data || !Array.isArray(apiResponse.data)) { + return []; + } + + return apiResponse.data.map((item: FindingGroupResourceApiItem) => ({ + id: item.id, + rowType: FINDINGS_ROW_TYPE.RESOURCE, + findingId: item.id, + checkId, + providerType: (item.attributes.provider?.type || "aws") as ProviderType, + providerAlias: item.attributes.provider?.alias || "", + providerUid: item.attributes.provider?.uid || "", + resourceName: item.attributes.resource?.name || "-", + resourceGroup: item.attributes.resource?.resource_group || "-", + resourceUid: item.attributes.resource?.uid || "-", + service: item.attributes.resource?.service || "-", + region: item.attributes.resource?.region || "-", + severity: (item.attributes.severity || "informational") as Severity, + status: item.attributes.status, + isMuted: item.attributes.status === "MUTED", + // TODO: remove fallback once the API returns muted_reason in finding-group-resources + mutedReason: item.attributes.muted_reason || undefined, + firstSeenAt: item.attributes.first_seen_at, + lastSeenAt: item.attributes.last_seen_at, + })); +} diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts new file mode 100644 index 0000000000..a58a5ba5a4 --- /dev/null +++ b/ui/actions/finding-groups/finding-groups.ts @@ -0,0 +1,144 @@ +"use server"; + +import { redirect } from "next/navigation"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +/** + * Maps filter[search] to filter[check_title__icontains] for finding-groups. + * The finding-groups endpoint supports check_title__icontains for substring + * matching on the human-readable check title displayed in the table. + */ +function mapSearchFilter( + filters: Record, +): Record { + const mapped = { ...filters }; + const searchValue = mapped["filter[search]"]; + if (searchValue) { + mapped["filter[check_title__icontains]"] = searchValue; + delete mapped["filter[search]"]; + } + return mapped; +} + +export const getFindingGroups = async ({ + page = 1, + pageSize = 10, + sort = "", + filters = {}, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) redirect("/findings"); + + const url = new URL(`${apiBaseUrl}/finding-groups`); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + if (sort) url.searchParams.append("sort", sort); + + appendSanitizedProviderFilters(url, mapSearchFilter(filters)); + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching finding groups:", error); + return undefined; + } +}; + +export const getLatestFindingGroups = async ({ + page = 1, + pageSize = 10, + sort = "", + filters = {}, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) redirect("/findings"); + + const url = new URL(`${apiBaseUrl}/finding-groups/latest`); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + if (sort) url.searchParams.append("sort", sort); + + appendSanitizedProviderFilters(url, mapSearchFilter(filters)); + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching latest finding groups:", error); + return undefined; + } +}; + +export const getFindingGroupResources = async ({ + checkId, + page = 1, + pageSize = 20, + filters = {}, +}: { + checkId: string; + page?: number; + pageSize?: number; + filters?: Record; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/finding-groups/${checkId}/resources`); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + + appendSanitizedProviderFilters(url, filters); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching finding group resources:", error); + return undefined; + } +}; + +export const getLatestFindingGroupResources = async ({ + checkId, + page = 1, + pageSize = 20, + filters = {}, +}: { + checkId: string; + page?: number; + pageSize?: number; + filters?: Record; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/finding-groups/latest/${checkId}/resources`, + ); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + + appendSanitizedProviderFilters(url, filters); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching latest finding group resources:", error); + return undefined; + } +}; diff --git a/ui/actions/finding-groups/index.ts b/ui/actions/finding-groups/index.ts new file mode 100644 index 0000000000..15fbbb78a9 --- /dev/null +++ b/ui/actions/finding-groups/index.ts @@ -0,0 +1,2 @@ +export * from "./finding-groups"; +export * from "./finding-groups.adapter"; diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts new file mode 100644 index 0000000000..9104092261 --- /dev/null +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -0,0 +1,224 @@ +import { createDict } from "@/lib"; +import { ProviderType, Severity } from "@/types"; + +export interface RemediationRecommendation { + text: string; + url: string; +} + +export interface RemediationCode { + cli: string; + other: string; + nativeiac: string; + terraform: string; +} + +export interface Remediation { + recommendation: RemediationRecommendation; + code: RemediationCode; +} + +export interface ScanInfo { + id: string; + name: string; + trigger: string; + state: string; + uniqueResourceCount: number; + progress: number; + duration: number; + startedAt: string | null; + completedAt: string | null; + insertedAt: string | null; + scheduledAt: string | null; +} + +/** + * Flattened finding for the resource detail drawer. + * Merges data from the finding attributes, its check_metadata, + * the included resource, and the included scan/provider. + */ +export interface ResourceDrawerFinding { + id: string; + uid: string; + checkId: string; + checkTitle: string; + status: string; + severity: Severity; + delta: string | null; + isMuted: boolean; + mutedReason: string | null; + firstSeenAt: string | null; + updatedAt: string | null; + // Resource + resourceId: string; + resourceUid: string; + resourceName: string; + resourceService: string; + resourceRegion: string; + resourceType: string; + resourceGroup: string; + // Provider + providerType: ProviderType; + providerAlias: string; + providerUid: string; + // Check metadata (flattened) + risk: string; + description: string; + statusExtended: string; + complianceFrameworks: string[]; + categories: string[]; + remediation: Remediation; + additionalUrls: string[]; + // Scan + scan: ScanInfo | null; +} + +/** + * Extracts unique compliance framework names from available data. + * + * Supports three shapes: + * 1a. check_metadata.compliance — array of { Framework, Version, ... } objects + * e.g. [{ Framework: "CIS-AWS", Version: "1.4" }, { Framework: "PCI-DSS" }] + * 1b. check_metadata.compliance — dict with framework keys and control arrays + * e.g. {"CIS-1.4": ["1.6"], "GDPR": ["article_25"], "HIPAA": ["164_312_d"]} + * 2. finding.compliance — dict with versioned keys (when API exposes it) + * e.g. {"CIS-AWS-1.4": ["2.1"], "PCI-DSS-3.2": ["6.2"]} + */ +function extractComplianceFrameworks( + metaCompliance: unknown, + findingCompliance: Record | null | undefined, +): string[] { + const frameworks = new Set(); + + // Source 1a: check_metadata.compliance — array of objects with Framework field + if (Array.isArray(metaCompliance)) { + for (const entry of metaCompliance) { + if (entry?.Framework || entry?.framework) { + frameworks.add(entry.Framework || entry.framework); + } + } + } + // Source 1b: check_metadata.compliance — dict keyed by framework name + else if (metaCompliance && typeof metaCompliance === "object") { + for (const key of Object.keys(metaCompliance as Record)) { + const base = key.replace(/-\d+(\.\d+)*$/, ""); + frameworks.add(base); + } + } + + // Source 2: finding.compliance — dict keys like "CIS-AWS-1.4" + if (findingCompliance && typeof findingCompliance === "object") { + for (const key of Object.keys(findingCompliance)) { + const base = key.replace(/-\d+(\.\d+)*$/, ""); + frameworks.add(base); + } + } + + return Array.from(frameworks).sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); +} + +/** + * Transforms the `/findings/latest?include=resources,scan.provider` response + * into a flat ResourceDrawerFinding array. + * + * Uses createDict to build lookup maps from the JSON:API `included` array, + * then resolves each finding's resource and provider relationships. + */ +export function adaptFindingsByResourceResponse( + apiResponse: any, +): ResourceDrawerFinding[] { + if (!apiResponse?.data || !Array.isArray(apiResponse.data)) { + return []; + } + + const resourcesDict = createDict("resources", apiResponse); + const scansDict = createDict("scans", apiResponse); + const providersDict = createDict("providers", apiResponse); + + return apiResponse.data.map((item: any) => { + const attrs = item.attributes; + const meta = attrs.check_metadata || {}; + const remediation = meta.remediation || { + recommendation: { text: "", url: "" }, + code: { cli: "", other: "", nativeiac: "", terraform: "" }, + }; + + // Resolve resource from included + const resourceRel = item.relationships?.resources?.data?.[0]; + const resource = resourceRel ? resourcesDict[resourceRel.id] : null; + const resourceAttrs = resource?.attributes || {}; + + // Resolve provider via scan → provider (include path: scan.provider) + const scanRel = item.relationships?.scan?.data; + const scan = scanRel ? scansDict[scanRel.id] : null; + const providerRelId = scan?.relationships?.provider?.data?.id ?? null; + const provider = providerRelId ? providersDict[providerRelId] : null; + const providerAttrs = provider?.attributes || {}; + + return { + id: item.id, + uid: attrs.uid, + checkId: attrs.check_id, + checkTitle: meta.checktitle || attrs.check_id, + status: attrs.status, + severity: (attrs.severity || "informational") as Severity, + delta: attrs.delta || null, + isMuted: Boolean(attrs.muted), + mutedReason: attrs.muted_reason || null, + firstSeenAt: attrs.first_seen_at || null, + updatedAt: attrs.updated_at || null, + // Resource + resourceId: resourceRel?.id || "", + resourceUid: resourceAttrs.uid || "-", + resourceName: resourceAttrs.name || "-", + resourceService: resourceAttrs.service || "-", + resourceRegion: resourceAttrs.region || "-", + resourceType: resourceAttrs.type || "-", + resourceGroup: meta.resourcegroup || "-", + // Provider + providerType: (providerAttrs.provider || "aws") as ProviderType, + providerAlias: providerAttrs.alias || "", + providerUid: providerAttrs.uid || "", + // Check metadata + risk: meta.risk || "", + description: meta.description || "", + statusExtended: attrs.status_extended || "", + complianceFrameworks: extractComplianceFrameworks( + meta.compliance ?? meta.Compliance, + attrs.compliance, + ), + categories: meta.categories || [], + remediation: { + recommendation: { + text: remediation.recommendation?.text || "", + url: remediation.recommendation?.url || "", + }, + code: { + cli: remediation.code?.cli || "", + other: remediation.code?.other || "", + nativeiac: remediation.code?.nativeiac || "", + terraform: remediation.code?.terraform || "", + }, + }, + additionalUrls: meta.additionalurls || [], + // Scan + scan: scan?.attributes + ? { + id: scan.id || "", + name: scan.attributes.name || "", + trigger: scan.attributes.trigger || "", + state: scan.attributes.state || "", + uniqueResourceCount: scan.attributes.unique_resource_count || 0, + progress: scan.attributes.progress || 0, + duration: scan.attributes.duration || 0, + startedAt: scan.attributes.started_at || null, + completedAt: scan.attributes.completed_at || null, + insertedAt: scan.attributes.inserted_at || null, + scheduledAt: scan.attributes.scheduled_at || null, + } + : null, + }; + }); +} diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts new file mode 100644 index 0000000000..bdc0765476 --- /dev/null +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiResponseMock, + appendSanitizedProviderTypeFiltersMock, + getFindingGroupResourcesMock, + getLatestFindingGroupResourcesMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + appendSanitizedProviderTypeFiltersMock: vi.fn( + (url: URL, filters: Record) => { + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]") { + url.searchParams.append(key, value); + } + }); + }, + ), + getFindingGroupResourcesMock: vi.fn(), + getLatestFindingGroupResourcesMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/provider-filters", () => ({ + appendSanitizedProviderTypeFilters: appendSanitizedProviderTypeFiltersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("@/actions/finding-groups", () => ({ + getFindingGroupResources: getFindingGroupResourcesMock, + getLatestFindingGroupResources: getLatestFindingGroupResourcesMock, +})); + +import { + resolveFindingIds, + resolveFindingIdsByCheckIds, + resolveFindingIdsByVisibleGroupResources, +} from "./findings-by-resource"; + +describe("resolveFindingIdsByCheckIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should resolve all finding IDs across every page for the latest endpoint", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + handleApiResponseMock + .mockResolvedValueOnce({ + data: [{ id: "finding-1" }, { id: "finding-2" }], + meta: { pagination: { pages: 3 } }, + }) + .mockResolvedValueOnce({ + data: [{ id: "finding-3" }], + meta: { pagination: { pages: 3 } }, + }) + .mockResolvedValueOnce({ + data: [{ id: "finding-4" }], + meta: { pagination: { pages: 3 } }, + }); + + // When + const result = await resolveFindingIdsByCheckIds({ + checkIds: ["check-1", "check-2"], + filters: { + "filter[provider_type__in]": "aws", + "filter[search]": "ignored-search", + }, + }); + + // Then + expect(result).toEqual([ + "finding-1", + "finding-2", + "finding-3", + "finding-4", + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + + const firstCallUrl = new URL(fetchMock.mock.calls[0][0]); + expect(firstCallUrl.pathname).toBe("/api/v1/findings/latest"); + expect(firstCallUrl.searchParams.get("filter[check_id__in]")).toBe( + "check-1,check-2", + ); + expect(firstCallUrl.searchParams.get("filter[muted]")).toBe("false"); + expect(firstCallUrl.searchParams.get("page[size]")).toBe("500"); + expect(firstCallUrl.searchParams.get("page[number]")).toBe("1"); + expect(firstCallUrl.searchParams.get("fields[findings]")).toBe("uid"); + expect(firstCallUrl.searchParams.get("filter[provider_type__in]")).toBe( + "aws", + ); + expect(firstCallUrl.searchParams.get("filter[search]")).toBeNull(); + + const laterPages = fetchMock.mock.calls + .slice(1) + .map(([url]) => new URL(url).searchParams.get("page[number]")); + expect(laterPages.sort()).toEqual(["2", "3"]); + }); + + it("should use the dated findings endpoint when date or scan filters are active", async () => { + // Given + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }], + meta: { pagination: { pages: 1 } }, + }); + + // When + await resolveFindingIdsByCheckIds({ + checkIds: ["check-1"], + hasDateOrScanFilter: true, + filters: { + "filter[scan__in]": "scan-1", + "filter[inserted_at__gte]": "2026-03-01", + }, + }); + + // Then + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings"); + expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe( + "2026-03-01", + ); + }); +}); + +describe("resolveFindingIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should use the dated findings endpoint when date or scan filters are active", async () => { + // Given + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }, { id: "finding-2" }], + }); + + // When + const result = await resolveFindingIds({ + checkId: "check-1", + resourceUids: ["resource-1", "resource-2"], + hasDateOrScanFilter: true, + filters: { + "filter[scan__in]": "scan-1", + "filter[inserted_at__gte]": "2026-03-01", + }, + }); + + // Then + expect(result).toEqual(["finding-1", "finding-2"]); + + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings"); + expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1"); + expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe( + "resource-1,resource-2", + ); + expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe( + "2026-03-01", + ); + }); +}); + +describe("resolveFindingIdsByVisibleGroupResources", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should resolve finding IDs from the group's visible resource UIDs instead of muting the whole check", async () => { + // Given + getLatestFindingGroupResourcesMock + .mockResolvedValueOnce({ + data: [ + { + id: "resource-row-1", + attributes: { + resource: { uid: "resource-1" }, + }, + }, + { + id: "resource-row-2", + attributes: { + resource: { uid: "resource-2" }, + }, + }, + ], + meta: { pagination: { pages: 2 } }, + }) + .mockResolvedValueOnce({ + data: [ + { + id: "resource-row-3", + attributes: { + resource: { uid: "resource-3" }, + }, + }, + ], + meta: { pagination: { pages: 2 } }, + }); + + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }], + }); + + // When + const result = await resolveFindingIdsByVisibleGroupResources({ + checkId: "check-1", + filters: { + "filter[provider_type__in]": "aws", + }, + resourceSearch: "visible subset", + }); + + // Then + expect(result).toEqual(["finding-1", "finding-2", "finding-3"]); + expect(getLatestFindingGroupResourcesMock).toHaveBeenCalledTimes(2); + expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(1, { + checkId: "check-1", + page: 1, + pageSize: 500, + filters: { + "filter[provider_type__in]": "aws", + "filter[name__icontains]": "visible subset", + }, + }); + expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(2, { + checkId: "check-1", + page: 2, + pageSize: 500, + filters: { + "filter[provider_type__in]": "aws", + "filter[name__icontains]": "visible subset", + }, + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings/latest"); + expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1"); + expect(calledUrl.searchParams.get("filter[check_id__in]")).toBeNull(); + expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe( + "resource-1,resource-2,resource-3", + ); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts new file mode 100644 index 0000000000..84ae5108f3 --- /dev/null +++ b/ui/actions/findings/findings-by-resource.ts @@ -0,0 +1,385 @@ +"use server"; + +import { + getFindingGroupResources, + getLatestFindingGroupResources, +} from "@/actions/finding-groups"; +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { runWithConcurrencyLimit } from "@/lib/concurrency"; +import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500; +const FINDING_IDS_RESOLUTION_CONCURRENCY = 4; +const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500; +const FINDING_FIELDS = "uid"; + +interface ResolveFindingIdsByCheckIdsParams { + checkIds: string[]; + filters?: Record; + hasDateOrScanFilter?: boolean; +} + +interface ResolveFindingIdsParams { + checkId: string; + resourceUids: string[]; + filters?: Record; + hasDateOrScanFilter?: boolean; +} + +interface ResolveFindingIdsByVisibleGroupResourcesParams { + checkId: string; + filters?: Record; + hasDateOrScanFilter?: boolean; + resourceSearch?: string; +} + +interface FindingIdsPageResponse { + ids: string[]; + totalPages: number; +} + +interface FindingGroupResourceUidsPageResponse { + resourceUids: string[]; + totalPages: number; +} + +function createFindingsResolutionUrl({ + checkIds, + filters = {}, + page, + hasDateOrScanFilter = false, +}: ResolveFindingIdsByCheckIdsParams & { + page: number; +}): URL { + const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest"; + const url = new URL(`${apiBaseUrl}/${endpoint}`); + + url.searchParams.append("filter[check_id__in]", checkIds.join(",")); + url.searchParams.append("filter[muted]", "false"); + url.searchParams.append("fields[findings]", FINDING_FIELDS); + url.searchParams.append("page[number]", page.toString()); + url.searchParams.append( + "page[size]", + FINDING_IDS_RESOLUTION_PAGE_SIZE.toString(), + ); + + appendSanitizedProviderTypeFilters(url, filters); + + return url; +} + +async function fetchFindingIdsPage({ + headers, + page, + ...params +}: ResolveFindingIdsByCheckIdsParams & { + headers: HeadersInit; + page: number; +}): Promise { + const response = await fetch( + createFindingsResolutionUrl({ ...params, page }).toString(), + { + headers, + }, + ); + const data = await handleApiResponse(response); + + if (!data?.data || !Array.isArray(data.data)) { + return { ids: [], totalPages: 1 }; + } + + return { + ids: data.data + .map((item: { id?: string }) => item.id) + .filter((id: string | undefined): id is string => Boolean(id)), + totalPages: data?.meta?.pagination?.pages ?? 1, + }; +} + +function chunkValues(values: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)); + } + return chunks; +} + +function createResourceFindingResolutionUrl({ + checkId, + resourceUids, + filters = {}, + hasDateOrScanFilter = false, +}: ResolveFindingIdsParams): URL { + const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest"; + const url = new URL(`${apiBaseUrl}/${endpoint}`); + + url.searchParams.append("filter[check_id]", checkId); + url.searchParams.append("filter[resource_uid__in]", resourceUids.join(",")); + url.searchParams.append("filter[muted]", "false"); + url.searchParams.append("page[size]", resourceUids.length.toString()); + + appendSanitizedProviderTypeFilters(url, filters); + + return url; +} + +async function fetchFindingIdsForResourceUids({ + headers, + ...params +}: ResolveFindingIdsParams & { + headers: HeadersInit; +}): Promise { + const response = await fetch( + createResourceFindingResolutionUrl(params).toString(), + { + headers, + }, + ); + const data = await handleApiResponse(response); + + if (!data?.data || !Array.isArray(data.data)) { + return []; + } + + return data.data + .map((item: { id?: string }) => item.id) + .filter((id: string | undefined): id is string => Boolean(id)); +} + +function buildFindingGroupResourceFilters({ + filters = {}, + resourceSearch, +}: Pick< + ResolveFindingIdsByVisibleGroupResourcesParams, + "filters" | "resourceSearch" +>): Record { + const nextFilters = { ...filters }; + if (resourceSearch) { + nextFilters["filter[name__icontains]"] = resourceSearch; + } + return nextFilters; +} + +async function fetchFindingGroupResourceUidsPage({ + checkId, + filters = {}, + hasDateOrScanFilter = false, + page, + resourceSearch, +}: ResolveFindingIdsByVisibleGroupResourcesParams & { + page: number; +}): Promise { + const fetchFn = hasDateOrScanFilter + ? getFindingGroupResources + : getLatestFindingGroupResources; + + const response = await fetchFn({ + checkId, + page, + pageSize: FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE, + filters: buildFindingGroupResourceFilters({ filters, resourceSearch }), + }); + + const data = response?.data; + + if (!data || !Array.isArray(data)) { + return { resourceUids: [], totalPages: 1 }; + } + + return { + resourceUids: data + .map( + (item: { attributes?: { resource?: { uid?: string } } }) => + item.attributes?.resource?.uid, + ) + .filter((uid: string | undefined): uid is string => Boolean(uid)), + totalPages: response?.meta?.pagination?.pages ?? 1, + }; +} + +/** + * Resolves resource UIDs + check ID into actual finding UUIDs. + * Uses /findings/latest (or /findings when date/scan filters are active) + * with check_id and resource_uid__in filters to batch-resolve actual finding IDs. + */ +export const resolveFindingIds = async ({ + checkId, + resourceUids, + filters = {}, + hasDateOrScanFilter = false, +}: ResolveFindingIdsParams): Promise => { + if (resourceUids.length === 0) { + return []; + } + + const headers = await getAuthHeaders({ contentType: false }); + const resourceUidChunks = chunkValues( + Array.from(new Set(resourceUids)), + FINDING_IDS_RESOLUTION_PAGE_SIZE, + ); + + try { + const results = await runWithConcurrencyLimit( + resourceUidChunks, + FINDING_IDS_RESOLUTION_CONCURRENCY, + (resourceUidChunk) => + fetchFindingIdsForResourceUids({ + checkId, + resourceUids: resourceUidChunk, + filters, + hasDateOrScanFilter, + headers, + }), + ); + + return Array.from(new Set(results.flat())); + } catch (error) { + console.error("Error resolving finding IDs:", error); + return []; + } +}; + +/** + * Resolves check IDs into actual finding UUIDs. + * Used at the group level where each row represents a check_id. + */ +export const resolveFindingIdsByCheckIds = async ({ + checkIds, + filters = {}, + hasDateOrScanFilter = false, +}: ResolveFindingIdsByCheckIdsParams): Promise => { + if (checkIds.length === 0) { + return []; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const firstPage = await fetchFindingIdsPage({ + checkIds, + filters, + hasDateOrScanFilter, + headers, + page: 1, + }); + + const remainingPages = Array.from( + { length: Math.max(0, firstPage.totalPages - 1) }, + (_, index) => index + 2, + ); + + const remainingResults = await runWithConcurrencyLimit( + remainingPages, + FINDING_IDS_RESOLUTION_CONCURRENCY, + async (page) => + fetchFindingIdsPage({ + checkIds, + filters, + hasDateOrScanFilter, + headers, + page, + }), + ); + + return Array.from( + new Set([ + ...firstPage.ids, + ...remainingResults.flatMap((result) => result.ids), + ]), + ); + } catch (error) { + console.error("Error resolving finding IDs by check IDs:", error); + return []; + } +}; + +/** + * Resolves a finding-group row to the actual findings for the resources + * currently visible in that group. + */ +export const resolveFindingIdsByVisibleGroupResources = async ({ + checkId, + filters = {}, + hasDateOrScanFilter = false, + resourceSearch, +}: ResolveFindingIdsByVisibleGroupResourcesParams): Promise => { + try { + const firstPage = await fetchFindingGroupResourceUidsPage({ + checkId, + filters, + hasDateOrScanFilter, + page: 1, + resourceSearch, + }); + + const remainingPages = Array.from( + { length: Math.max(0, firstPage.totalPages - 1) }, + (_, index) => index + 2, + ); + + const remainingResults = await runWithConcurrencyLimit( + remainingPages, + FINDING_IDS_RESOLUTION_CONCURRENCY, + (page) => + fetchFindingGroupResourceUidsPage({ + checkId, + filters, + hasDateOrScanFilter, + page, + resourceSearch, + }), + ); + + const resourceUids = Array.from( + new Set([ + ...firstPage.resourceUids, + ...remainingResults.flatMap((result) => result.resourceUids), + ]), + ); + + return resolveFindingIds({ + checkId, + resourceUids, + filters, + hasDateOrScanFilter, + }); + } catch (error) { + console.error( + "Error resolving finding IDs from visible group resources:", + error, + ); + return []; + } +}; + +export const getLatestFindingsByResourceUid = async ({ + resourceUid, + page = 1, + pageSize = 50, +}: { + resourceUid: string; + page?: number; + pageSize?: number; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/findings/latest?include=resources,scan.provider`, + ); + + url.searchParams.append("filter[resource_uid]", resourceUid); + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + + try { + const findings = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(findings); + } catch (error) { + console.error("Error fetching findings by resource UID:", error); + return undefined; + } +}; diff --git a/ui/actions/findings/index.ts b/ui/actions/findings/index.ts index eb3a674c67..d9fd5ae3b5 100644 --- a/ui/actions/findings/index.ts +++ b/ui/actions/findings/index.ts @@ -1 +1,3 @@ export * from "./findings"; +export * from "./findings-by-resource"; +export * from "./findings-by-resource.adapter"; diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts index d8c5b7bbd4..cc31bd90bd 100644 --- a/ui/actions/mute-rules/mute-rules.ts +++ b/ui/actions/mute-rules/mute-rules.ts @@ -182,6 +182,9 @@ export const createMuteRule = async ( } }; +// Note: Adding findings to existing mute rules is not supported by the API. +// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields. +// finding_ids can only be specified when creating a new mute rule. export const updateMuteRule = async ( _prevState: MuteRuleActionState, formData: FormData, @@ -374,10 +377,6 @@ export const deleteMuteRule = async ( } }; -// Note: Adding findings to existing mute rules is not supported by the API. -// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields. -// finding_ids can only be specified when creating a new mute rule. - // Note: Unmute functionality is not currently supported by the API. // The FindingViewSet only allows GET operations, and deleting a mute rule // does not unmute the findings ("Previously muted findings remain muted"). diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 74d728fd74..f324260e00 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -222,7 +222,11 @@ export const ProviderTypeSelector = ({ // .filter((p) => p.attributes.connection?.connected) .map((p) => p.attributes.provider), ), - ).filter((type): type is ProviderType => type in PROVIDER_DATA); + ) + .filter((type): type is ProviderType => type in PROVIDER_DATA) + .sort((a, b) => + PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label), + ); const renderIcon = (providerType: ProviderType) => { const IconComponent = PROVIDER_DATA[providerType].icon; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx index be4bd1b528..7b5645dfdf 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx @@ -1,6 +1,6 @@ "use client"; -import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; /** * Loading skeleton for graph visualization @@ -12,7 +12,7 @@ export const GraphLoading = () => { data-testid="graph-loading" className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center" > - +

Loading Attack Paths graph...

diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx index 73ceb1e6cf..aeb8b897f5 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/shadcn/button/button"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; import { EntityInfo } from "@/components/ui/entities/entity-info"; import { DataTable, DataTableColumnHeader } from "@/components/ui/table"; +import { formatDuration } from "@/lib/date-utils"; import type { MetaDataProps, ProviderType } from "@/types"; import type { AttackPathScan, ScanState } from "@/types/attack-paths"; import { SCAN_STATES } from "@/types/attack-paths"; @@ -32,10 +33,9 @@ const parsePageParam = (value: string | null, fallback: number) => { return Number.isNaN(parsedValue) || parsedValue < 1 ? fallback : parsedValue; }; -const formatDuration = (duration: number | null) => { +const formatNullableDuration = (duration: number | null) => { if (!duration) return "-"; - - return `${Math.floor(duration / 60)}m ${duration % 60}s`; + return formatDuration(duration); }; const isSelectDisabled = ( @@ -164,7 +164,7 @@ const getColumns = ({ ), cell: ({ row }) => ( - {formatDuration(row.original.attributes.duration)} + {formatNullableDuration(row.original.attributes.duration)} ), enableSorting: false, diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 30a258e61b..576d34ca1e 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -1,31 +1,28 @@ import { Suspense } from "react"; import { - getFindingById, - getFindings, - getLatestFindings, - getLatestMetadataInfo, - getMetadataInfo, -} from "@/actions/findings"; + adaptFindingGroupsResponse, + getFindingGroups, + getLatestFindingGroups, +} from "@/actions/finding-groups"; +import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; import { getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; -import { FindingDetailsSheet } from "@/components/findings"; import { FindingsFilters } from "@/components/findings/findings-filters"; import { - FindingsTableWithSelection, + FindingsGroupTable, SkeletonTableFindings, } from "@/components/findings/table"; import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { - createDict, createScanDetailsMapping, extractFiltersAndQuery, extractSortAndKey, hasDateOrScanFilter, } from "@/lib"; import { ScanEntity, ScanProps } from "@/types"; -import { FindingProps, SearchParamsProps } from "@/types/components"; +import { SearchParamsProps } from "@/types/components"; export default async function Findings({ searchParams, @@ -39,78 +36,18 @@ export default async function Findings({ // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - // Check if there's a specific finding ID to fetch - const findingId = resolvedSearchParams.id?.toString(); + // TODO: Re-implement deep link support (/findings?id=) using the grouped view's resource detail drawer + // once the legacy FindingDetailsSheet is fully deprecated (still used by /resources and overview dashboard). - const [metadataInfoData, providersData, scansData, findingByIdData] = - await Promise.all([ - (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ - query, - sort: encodedSort, - filters, - }), - getProviders({ pageSize: 50 }), - getScans({ pageSize: 50 }), - findingId - ? getFindingById(findingId, "resources,scan.provider") - : Promise.resolve(null), - ]); - - // Process the finding data to match the expected structure - const processedFinding = findingByIdData?.data - ? (() => { - const finding = findingByIdData.data; - const included = findingByIdData.included || []; - - // Build dictionaries from included data - type IncludedItem = { - type: string; - id: string; - attributes: Record; - relationships?: { - provider?: { data?: { id: string } }; - }; - }; - - const resourceDict: Record = {}; - const scanDict: Record = {}; - const providerDict: Record = {}; - - included.forEach((item: IncludedItem) => { - if (item.type === "resources") { - resourceDict[item.id] = { - id: item.id, - attributes: item.attributes, - }; - } else if (item.type === "scans") { - scanDict[item.id] = item; - } else if (item.type === "providers") { - providerDict[item.id] = { - id: item.id, - attributes: item.attributes, - }; - } - }); - - const scanId = finding.relationships?.scan?.data?.id; - const resourceId = finding.relationships?.resources?.data?.[0]?.id; - const scan = scanId ? scanDict[scanId] : undefined; - const providerId = scan?.relationships?.provider?.data?.id; - const resource = resourceId ? resourceDict[resourceId] : undefined; - const provider = providerId ? providerDict[providerId] : undefined; - - return { - ...finding, - relationships: { - scan: scan - ? { data: scan, attributes: scan.attributes } - : undefined, - resource: resource, - provider: provider, - }, - } as FindingProps; - })() - : null; + const [metadataInfoData, providersData, scansData] = await Promise.all([ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + sort: encodedSort, + filters, + }), + getProviders({ pageSize: 50 }), + getScans({ pageSize: 50 }), + ]); // Extract unique regions, services, categories, groups from the new endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; @@ -154,7 +91,6 @@ export default async function Findings({ - {processedFinding && } ); } @@ -166,64 +102,45 @@ const SSRDataTable = async ({ }) => { const page = parseInt(searchParams.page?.toString() || "1", 10); const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); - const defaultSort = "severity,status,-inserted_at"; + const defaultSort = "-severity,-fail_count,-last_seen_at"; const { encodedSort } = extractSortAndKey({ ...searchParams, sort: searchParams.sort ?? defaultSort, }); - const { filters, query } = extractFiltersAndQuery(searchParams); + const { filters } = extractFiltersAndQuery(searchParams); // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(searchParams); - const fetchFindings = hasDateOrScan ? getFindings : getLatestFindings; + const fetchFindingGroups = hasDateOrScan + ? getFindingGroups + : getLatestFindingGroups; - const findingsData = await fetchFindings({ - query, + const findingGroupsData = await fetchFindingGroups({ page, sort: encodedSort, filters, pageSize, }); - // Create dictionaries for resources, scans, and providers - const resourceDict = createDict("resources", findingsData); - const scanDict = createDict("scans", findingsData); - const providerDict = createDict("providers", findingsData); - - // Expand each finding with its corresponding resource, scan, and provider - const expandedFindings = findingsData?.data - ? findingsData.data.map((finding: FindingProps) => { - const scan = scanDict[finding.relationships?.scan?.data?.id]; - const resource = - resourceDict[finding.relationships?.resources?.data?.[0]?.id]; - const provider = providerDict[scan?.relationships?.provider?.data?.id]; - - return { - ...finding, - relationships: { scan, resource, provider }, - }; - }) - : []; - - // Create the new object while maintaining the original structure - const expandedResponse = { - ...findingsData, - data: expandedFindings, - }; + // Transform API response to FindingGroupRow[] + const groups = adaptFindingGroupsResponse(findingGroupsData); + // Key resets all client state (selection, drill-down) when data changes + const groupKey = groups.map((g) => g.id).join(","); return ( <> - {findingsData?.errors && ( + {findingGroupsData?.errors && (

Error:

-

{findingsData.errors[0].detail}

+

{findingGroupsData.errors[0].detail}

)} - ); diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index 5a9cea2454..eefca03e6b 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -10,7 +10,15 @@ import { ContentLayout } from "@/components/ui"; export const dynamic = "force-dynamic"; -export default async function AIChatbot() { +export default async function AIChatbot({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + const initialPrompt = + typeof params.prompt === "string" ? params.prompt : undefined; + const hasConfig = await isLighthouseConfigured(); if (!hasConfig) { @@ -33,6 +41,7 @@ export default async function AIChatbot() { providers={providersConfig.providers} defaultProviderId={providersConfig.defaultProviderId} defaultModelId={providersConfig.defaultModelId} + initialPrompt={initialPrompt} /> diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx index 70f437d967..48de820973 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx @@ -3,8 +3,6 @@ import { Pencil, Trash2 } from "lucide-react"; import { MuteRuleData } from "@/actions/mute-rules/types"; -import { VerticalDotsIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownDangerZone, @@ -24,20 +22,7 @@ export function MuteRuleRowActions({ }: MuteRuleRowActionsProps) { return (
- - - - } - > + } label="Edit Mute Rule" diff --git a/ui/components/feeds/feeds-client.tsx b/ui/components/feeds/feeds-client.tsx index 6e0528b5a1..e91e16b6d6 100644 --- a/ui/components/feeds/feeds-client.tsx +++ b/ui/components/feeds/feeds-client.tsx @@ -1,6 +1,5 @@ "use client"; -import { formatDistanceToNow, parseISO } from "date-fns"; import { BellRing, ExternalLink } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; @@ -19,6 +18,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { formatRelativeTime } from "@/lib/date-utils"; import { hasNewFeeds, markFeedsAsSeen } from "@/lib/feeds-storage"; import { cn } from "@/lib/utils"; @@ -145,9 +145,7 @@ interface FeedTimelineItemProps { } function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) { - const relativeTime = formatDistanceToNow(parseISO(item.pubDate), { - addSuffix: true, - }); + const relativeTime = formatRelativeTime(item.pubDate); // Extract version from title if it's a GitHub release const versionMatch = item.title.match(/v?(\d+\.\d+\.\d+)/); diff --git a/ui/components/findings/finding-details-sheet.tsx b/ui/components/findings/finding-details-sheet.tsx deleted file mode 100644 index ddea0e13c4..0000000000 --- a/ui/components/findings/finding-details-sheet.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { usePathname, useRouter, useSearchParams } from "next/navigation"; - -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { FindingProps } from "@/types/components"; - -import { FindingDetail } from "./table/finding-detail"; - -interface FindingDetailsSheetProps { - finding: FindingProps; -} - -export const FindingDetailsSheet = ({ finding }: FindingDetailsSheetProps) => { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const handleOpenChange = (open: boolean) => { - if (!open) { - const params = new URLSearchParams(searchParams.toString()); - params.delete("id"); - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - } - }; - - return ( - - - - Finding Details - - View the finding details - - - - - - ); -}; diff --git a/ui/components/findings/floating-mute-button.tsx b/ui/components/findings/floating-mute-button.tsx index 5b76d65fcc..9ecab3b846 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-mute-button.tsx @@ -4,6 +4,7 @@ import { VolumeX } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/shadcn"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { MuteFindingsModal } from "./mute-findings-modal"; @@ -11,32 +12,70 @@ interface FloatingMuteButtonProps { selectedCount: number; selectedFindingIds: string[]; onComplete?: () => void; + /** Async resolver that returns actual finding UUIDs before opening modal */ + onBeforeOpen?: () => Promise; + /** When true, the toast warns that processing may take a few minutes */ + isBulkOperation?: boolean; + /** Custom button label. Defaults to "Mute ({selectedCount})" */ + label?: string; } export function FloatingMuteButton({ selectedCount, selectedFindingIds, onComplete, + onBeforeOpen, + isBulkOperation = false, + label, }: FloatingMuteButtonProps) { const [isModalOpen, setIsModalOpen] = useState(false); + const [resolvedIds, setResolvedIds] = useState([]); + const [isResolving, setIsResolving] = useState(false); + + const handleClick = async () => { + if (onBeforeOpen) { + setIsResolving(true); + const ids = await onBeforeOpen(); + setResolvedIds(ids); + setIsResolving(false); + if (ids.length > 0) { + setIsModalOpen(true); + } + } else { + setIsModalOpen(true); + } + }; + + const handleComplete = () => { + setResolvedIds([]); + onComplete?.(); + }; + + const findingIds = onBeforeOpen ? resolvedIds : selectedFindingIds; return ( <>
diff --git a/ui/components/findings/index.ts b/ui/components/findings/index.ts index a43ec5f622..2e2bece9f7 100644 --- a/ui/components/findings/index.ts +++ b/ui/components/findings/index.ts @@ -1,2 +1 @@ -export * from "./finding-details-sheet"; export * from "./muted"; diff --git a/ui/components/findings/markdown-container.tsx b/ui/components/findings/markdown-container.tsx new file mode 100644 index 0000000000..5756a8ad5c --- /dev/null +++ b/ui/components/findings/markdown-container.tsx @@ -0,0 +1,11 @@ +import ReactMarkdown from "react-markdown"; + +interface MarkdownContainerProps { + children: string; +} + +export const MarkdownContainer = ({ children }: MarkdownContainerProps) => ( +
+ {children} +
+); diff --git a/ui/components/findings/mute-findings-modal.tsx b/ui/components/findings/mute-findings-modal.tsx index a3db0e81df..4f4e4e2ccb 100644 --- a/ui/components/findings/mute-findings-modal.tsx +++ b/ui/components/findings/mute-findings-modal.tsx @@ -1,14 +1,7 @@ "use client"; import { Input, Textarea } from "@heroui/input"; -import { - Dispatch, - SetStateAction, - useEffect, - useRef, - useState, - useTransition, -} from "react"; +import { Dispatch, SetStateAction, useState, useTransition } from "react"; import { createMuteRule } from "@/actions/mute-rules"; import { MuteRuleActionState } from "@/actions/mute-rules/types"; @@ -21,6 +14,7 @@ interface MuteFindingsModalProps { onOpenChange: Dispatch>; findingIds: string[]; onComplete?: () => void; + isBulkOperation?: boolean; } export function MuteFindingsModal({ @@ -28,35 +22,12 @@ export function MuteFindingsModal({ onOpenChange, findingIds, onComplete, + isBulkOperation = false, }: MuteFindingsModalProps) { const { toast } = useToast(); const [state, setState] = useState(null); const [isPending, startTransition] = useTransition(); - // Use refs to avoid stale closures in useEffect - const onCompleteRef = useRef(onComplete); - onCompleteRef.current = onComplete; - - const onOpenChangeRef = useRef(onOpenChange); - onOpenChangeRef.current = onOpenChange; - - useEffect(() => { - if (state?.success) { - toast({ - title: "Success", - description: state.success, - }); - onCompleteRef.current?.(); - onOpenChangeRef.current(false); - } else if (state?.errors?.general) { - toast({ - variant: "destructive", - title: "Error", - description: state.errors.general, - }); - } - }, [state, toast]); - const handleCancel = () => { onOpenChange(false); }; @@ -77,6 +48,24 @@ export function MuteFindingsModal({ startTransition(() => { void (async () => { const result = await createMuteRule(null, formData); + if (!result) return; + + if (result.success) { + toast({ + title: "Success", + description: isBulkOperation + ? "Mute rule created. It may take a few minutes for all findings to update." + : result.success, + }); + onComplete?.(); + onOpenChange(false); + } else if (result.errors?.general) { + toast({ + variant: "destructive", + title: "Error", + description: result.errors.general, + }); + } setState(result); })(); }); diff --git a/ui/components/findings/muted.tsx b/ui/components/findings/muted.tsx index 9ed971d1b9..59b6f86bad 100644 --- a/ui/components/findings/muted.tsx +++ b/ui/components/findings/muted.tsx @@ -1,4 +1,8 @@ -import { Tooltip } from "@heroui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { MutedIcon } from "../icons"; @@ -14,10 +18,16 @@ export const Muted = ({ if (isMuted === false) return null; return ( - -
- -
+ + +
+ + Muted +
+
+ + {mutedReason} +
); }; diff --git a/ui/components/findings/table/column-finding-groups.tsx b/ui/components/findings/table/column-finding-groups.tsx new file mode 100644 index 0000000000..0a5da5cfa8 --- /dev/null +++ b/ui/components/findings/table/column-finding-groups.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; +import { ChevronRight } from "lucide-react"; + +import { Checkbox } from "@/components/shadcn"; +import { + DataTableColumnHeader, + SeverityBadge, + StatusFindingBadge, +} from "@/components/ui/table"; +import { cn } from "@/lib"; +import { FindingGroupRow, ProviderType } from "@/types"; + +import { DataTableRowActions } from "./data-table-row-actions"; +import { ImpactedProvidersCell } from "./impacted-providers-cell"; +import { ImpactedResourcesCell } from "./impacted-resources-cell"; +import { DeltaValues, NotificationIndicator } from "./notification-indicator"; + +interface GetColumnFindingGroupsOptions { + rowSelection: RowSelectionState; + selectableRowCount: number; + onDrillDown: (checkId: string, group: FindingGroupRow) => void; + expandedCheckId?: string | null; + /** True when the expanded group has individually selected resources */ + hasResourceSelection?: boolean; +} + +export function getColumnFindingGroups({ + rowSelection, + selectableRowCount, + onDrillDown, + expandedCheckId, + hasResourceSelection = false, +}: GetColumnFindingGroupsOptions): ColumnDef[] { + const selectedCount = Object.values(rowSelection).filter(Boolean).length; + const isAllSelected = + selectedCount > 0 && selectedCount === selectableRowCount; + const isSomeSelected = + selectedCount > 0 && selectedCount < selectableRowCount; + + return [ + // Combined column: notification + expand toggle + checkbox + { + id: "select", + header: ({ table }) => { + const headerChecked = isAllSelected + ? true + : isSomeSelected + ? "indeterminate" + : false; + + return ( +
+
+
+ + table.toggleAllPageRowsSelected(checked === true) + } + onClick={(e) => e.stopPropagation()} + aria-label="Select all" + disabled={selectableRowCount === 0} + /> +
+ ); + }, + cell: ({ row }) => { + const group = row.original; + const allMuted = + group.mutedCount > 0 && group.mutedCount === group.resourcesTotal; + const isExpanded = expandedCheckId === group.checkId; + + const delta = + group.newCount > 0 + ? DeltaValues.NEW + : group.changedCount > 0 + ? DeltaValues.CHANGED + : DeltaValues.NONE; + + return ( +
+ + + { + // When indeterminate (resources selected), clicking deselects the group + if ( + rowSelection[row.id] && + isExpanded && + hasResourceSelection + ) { + row.toggleSelected(false); + } else { + row.toggleSelected(checked === true); + } + }} + onClick={(e) => e.stopPropagation()} + aria-label="Select row" + /> +
+ ); + }, + enableSorting: false, + enableHiding: false, + }, + // Status column — not sortable on finding-groups endpoint + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const rawStatus = row.original.status as string; + const status = rawStatus === "MUTED" ? "FAIL" : row.original.status; + return ; + }, + enableSorting: false, + }, + // Finding title column + { + accessorKey: "finding", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const group = row.original; + + return ( +
+

onDrillDown(group.checkId, group)} + > + {group.checkTitle} +

+
+ ); + }, + }, + // Severity column + { + accessorKey: "severity", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + // Impacted Providers column + { + id: "impactedProviders", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + // Impacted Resources column + { + id: "impactedResources", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const group = row.original; + return ( + + ); + }, + enableSorting: false, + }, + // Actions column + { + id: "actions", + header: () =>
, + cell: ({ row }) => , + enableSorting: false, + }, + ]; +} diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx new file mode 100644 index 0000000000..e8044b6383 --- /dev/null +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table"; +import { Container, CornerDownRight, VolumeOff, VolumeX } from "lucide-react"; +import { useContext, useState } from "react"; + +import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; +import { Checkbox } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { InfoField } from "@/components/shadcn/info-field/info-field"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { DateWithTime } from "@/components/ui/entities"; +import { EntityInfo } from "@/components/ui/entities/entity-info"; +import { SeverityBadge } from "@/components/ui/table"; +import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header"; +import { + type FindingStatus, + StatusFindingBadge, +} from "@/components/ui/table/status-finding-badge"; +import { getFailingForLabel } from "@/lib/date-utils"; +import { FindingResourceRow } from "@/types"; + +import { FindingsSelectionContext } from "./findings-selection-context"; +import { NotificationIndicator } from "./notification-indicator"; + +const ResourceRowActions = ({ row }: { row: Row }) => { + const resource = row.original; + const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + const [resolvedIds, setResolvedIds] = useState([]); + const [isResolving, setIsResolving] = useState(false); + + const { selectedFindingIds, clearSelection, resolveMuteIds, onMuteComplete } = + useContext(FindingsSelectionContext) || { + selectedFindingIds: [], + clearSelection: () => {}, + }; + + const isCurrentSelected = selectedFindingIds.includes(resource.findingId); + const hasMultipleSelected = selectedFindingIds.length > 1; + + const getDisplayIds = (): string[] => { + if (isCurrentSelected && hasMultipleSelected) { + return selectedFindingIds; + } + return [resource.findingId]; + }; + + const getMuteLabel = () => { + if (resource.isMuted) return "Muted"; + const ids = getDisplayIds(); + if (ids.length > 1) return `Mute ${ids.length}`; + return "Mute"; + }; + + const handleMuteClick = async () => { + const displayIds = getDisplayIds(); + + if (resolveMuteIds) { + setIsResolving(true); + const ids = await resolveMuteIds(displayIds); + setResolvedIds(ids); + setIsResolving(false); + if (ids.length > 0) setIsMuteModalOpen(true); + } else { + setResolvedIds(displayIds); + setIsMuteModalOpen(true); + } + }; + + const handleMuteComplete = () => { + clearSelection(); + setResolvedIds([]); + onMuteComplete?.(); + }; + + return ( + <> + {!resource.isMuted && ( + + )} +
e.stopPropagation()} + > + + + ) : isResolving ? ( + + ) : ( + + ) + } + label={isResolving ? "Resolving..." : getMuteLabel()} + disabled={resource.isMuted || isResolving} + onSelect={handleMuteClick} + /> + +
+ + ); +}; + +interface GetColumnFindingResourcesOptions { + rowSelection: RowSelectionState; + selectableRowCount: number; +} + +export function getColumnFindingResources({ + rowSelection, + selectableRowCount, +}: GetColumnFindingResourcesOptions): ColumnDef[] { + const selectedCount = Object.values(rowSelection).filter(Boolean).length; + const isAllSelected = + selectedCount > 0 && selectedCount === selectableRowCount; + const isSomeSelected = + selectedCount > 0 && selectedCount < selectableRowCount; + + return [ + // Combined column: notification + child icon + checkbox + { + id: "select", + header: ({ table }) => { + const headerChecked = isAllSelected + ? true + : isSomeSelected + ? "indeterminate" + : false; + + return ( +
+
+
+ + table.toggleAllPageRowsSelected(checked === true) + } + onClick={(e) => e.stopPropagation()} + aria-label="Select all resources" + disabled={selectableRowCount === 0} + /> +
+ ); + }, + cell: ({ row }) => ( +
+ + + row.toggleSelected(checked === true)} + onClick={(e) => e.stopPropagation()} + aria-label="Select resource" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + // Resource — name + uid (EntityInfo with resource icon) + { + id: "resource", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ } + entityAlias={row.original.resourceGroup} + entityId={row.original.resourceUid} + /> +
+ ), + enableSorting: false, + }, + // Status + { + id: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const rawStatus = row.original.status; + const status = + rawStatus === "MUTED" ? "FAIL" : (rawStatus as FindingStatus); + return ; + }, + enableSorting: false, + }, + // Service + { + id: "service", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +

+ {row.original.service} +

+ ), + enableSorting: false, + }, + // Region + { + id: "region", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +

+ {row.original.region} +

+ ), + enableSorting: false, + }, + // Severity + { + id: "severity", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableSorting: false, + }, + // Account — alias + uid (EntityInfo with provider logo) + { + id: "account", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + }, + // Last seen + { + id: "lastSeen", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + + + ), + enableSorting: false, + }, + // Failing for — duration since first_seen_at + { + id: "failingFor", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const duration = getFailingForLabel(row.original.firstSeenAt); + return ( + + {duration || "-"} + + ); + }, + enableSorting: false, + }, + // Actions column — mute only + { + id: "actions", + header: () =>
, + cell: ({ row }) => , + enableSorting: false, + }, + ]; +} diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index ea2c8ae464..56fa41e135 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -1,3 +1,5 @@ +// TODO: Legacy columns — used by overview dashboard (column-new-findings-to-date.tsx). +// Migrate that consumer to grouped view columns, then delete this file. "use client"; import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 752f9f5144..11644e731a 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -7,23 +7,48 @@ import { useContext, useState } from "react"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; -import { VerticalDotsIcon } from "@/components/icons"; import { JiraIcon } from "@/components/icons/services/IconServices"; import { ActionDropdown, ActionDropdownItem, } from "@/components/shadcn/dropdown"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { FindingsSelectionContext } from "./findings-selection-context"; export interface FindingRowData { id: string; - attributes: { + attributes?: { muted?: boolean; check_metadata?: { checktitle?: string; }; }; + // Flat shape for FindingGroupRow + rowType?: string; + checkId?: string; + checkTitle?: string; + mutedCount?: number; + resourcesTotal?: number; +} + +/** + * Extract muted state and title from either FindingProps (nested attributes) + * or FindingGroupRow (flat shape with rowType discriminant). + */ +function extractRowInfo(data: FindingRowData) { + if (data.rowType === "group") { + const allMuted = + (data.mutedCount ?? 0) > 0 && data.mutedCount === data.resourcesTotal; + return { + isMuted: allMuted, + title: data.checkTitle || "Security Finding", + }; + } + return { + isMuted: data.attributes?.muted ?? false, + title: data.attributes?.check_metadata?.checktitle || "Security Finding", + }; } interface DataTableRowActionsProps { @@ -40,48 +65,68 @@ export function DataTableRowActions({ const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); - const isMuted = finding.attributes.muted; + const { isMuted, title: findingTitle } = extractRowInfo(finding); // Get selection context - if there are other selected rows, include them const selectionContext = useContext(FindingsSelectionContext); - const { selectedFindingIds, clearSelection } = selectionContext || { - selectedFindingIds: [], - clearSelection: () => {}, - }; + const { selectedFindingIds, clearSelection, resolveMuteIds } = + selectionContext || { + selectedFindingIds: [], + clearSelection: () => {}, + }; - const findingTitle = - finding.attributes.check_metadata?.checktitle || "Security Finding"; + const [resolvedIds, setResolvedIds] = useState([]); + const [isResolving, setIsResolving] = useState(false); + + // For group rows, use checkId (for the resolve API); for regular findings, use id (UUID). + const isGroup = finding.rowType === "group"; + const muteKey = isGroup ? (finding.checkId ?? finding.id) : finding.id; // If current finding is selected and there are multiple selections, mute all // Otherwise, just mute this single finding - const isCurrentSelected = selectedFindingIds.includes(finding.id); + const isCurrentSelected = selectedFindingIds.includes(muteKey); const hasMultipleSelected = selectedFindingIds.length > 1; - const getMuteIds = (): string[] => { + const getDisplayIds = (): string[] => { if (isCurrentSelected && hasMultipleSelected) { - // Mute all selected including current return selectedFindingIds; } - // Just mute the current finding - return [finding.id]; + return [muteKey]; }; const getMuteLabel = () => { if (isMuted) return "Muted"; - const ids = getMuteIds(); + const ids = getDisplayIds(); if (ids.length > 1) { - return `Mute ${ids.length} Findings`; + return `Mute ${ids.length} ${isGroup ? "Finding Groups" : "Findings"}`; + } + return isGroup ? "Mute Finding Group" : "Mute Finding"; + }; + + const handleMuteClick = async () => { + const displayIds = getDisplayIds(); + + if (resolveMuteIds) { + setIsResolving(true); + const ids = await resolveMuteIds(displayIds); + setResolvedIds(ids); + setIsResolving(false); + if (ids.length > 0) setIsMuteModalOpen(true); + } else { + // Regular findings — IDs are already valid finding UUIDs + setResolvedIds(displayIds); + setIsMuteModalOpen(true); } - return "Mute Finding"; }; const handleMuteComplete = () => { // Always clear selection when a finding is muted because: - // 1. If the muted finding was selected, its index now points to a different finding - // 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected + // rowSelection uses indices (0, 1, 2...) not IDs, so after refresh + // the wrong findings would appear selected clearSelection(); + setResolvedIds([]); if (onMuteComplete) { - onMuteComplete(getMuteIds()); + onMuteComplete(getDisplayIds()); return; } @@ -90,55 +135,46 @@ export function DataTableRowActions({ return ( <> - + {!isGroup && ( + + )}
- - - - } - ariaLabel="Finding actions" - > + + ) : isResolving ? ( + ) : ( ) } - label={getMuteLabel()} - disabled={isMuted} - onSelect={() => { - setIsMuteModalOpen(true); - }} - /> - } - label="Send to Jira" - onSelect={() => setIsJiraModalOpen(true)} + label={isResolving ? "Resolving..." : getMuteLabel()} + disabled={isMuted || isResolving} + onSelect={handleMuteClick} /> + {!isGroup && ( + } + label="Send to Jira" + onSelect={() => setIsJiraModalOpen(true)} + /> + )}
diff --git a/ui/components/findings/table/delta-indicator.tsx b/ui/components/findings/table/delta-indicator.tsx index a1019cb840..7f79d1ede2 100644 --- a/ui/components/findings/table/delta-indicator.tsx +++ b/ui/components/findings/table/delta-indicator.tsx @@ -1,6 +1,9 @@ -import { Tooltip } from "@heroui/tooltip"; - import { Button } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { DOCS_URLS } from "@/lib/external-urls"; import { cn } from "@/lib/utils"; @@ -10,9 +13,20 @@ interface DeltaIndicatorProps { export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => { return ( - + +
+ +
{delta === "new" @@ -35,18 +49,7 @@ export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
- } - > -
+ ); }; diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index d8a3918e14..1b95a6653b 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -1,9 +1,10 @@ +// TODO: Legacy component — used by /resources page and overview dashboard. +// Migrate those consumers to the new resource-detail-drawer, then delete this file. "use client"; import { ExternalLink, Link, VolumeX, X } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { type ReactNode, useEffect, useState } from "react"; -import ReactMarkdown from "react-markdown"; +import { type ReactNode, useState } from "react"; import { Button, @@ -33,41 +34,20 @@ import { FindingStatus, StatusFindingBadge, } from "@/components/ui/table/status-finding-badge"; +import { formatDuration } from "@/lib/date-utils"; import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils"; import { cn } from "@/lib/utils"; import { FindingProps, ProviderType } from "@/types"; +import { MarkdownContainer } from "../markdown-container"; import { MuteFindingsModal } from "../mute-findings-modal"; import { Muted } from "../muted"; import { DeltaIndicator } from "./delta-indicator"; -const MarkdownContainer = ({ children }: { children: string }) => { - return ( -
- {children} -
- ); -}; - const renderValue = (value: string | null | undefined) => { return value && value.trim() !== "" ? value : "-"; }; -// Add new utility function for duration formatting -const formatDuration = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - const parts = []; - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (remainingSeconds > 0 || parts.length === 0) - parts.push(`${remainingSeconds}s`); - - return parts.join(" "); -}; - interface FindingDetailProps { findingDetails: FindingProps; trigger?: ReactNode; @@ -93,12 +73,6 @@ export const FindingDetail = ({ const searchParams = useSearchParams(); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); - const [activeTab, setActiveTab] = useState("general"); - - useEffect(() => { - setActiveTab("general"); - }, [findingDetails.id]); - const copyFindingUrl = () => { const params = new URLSearchParams(searchParams.toString()); params.set("id", findingDetails.id); @@ -179,7 +153,7 @@ export const FindingDetail = ({
{/* Tabs */} - +
General diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx new file mode 100644 index 0000000000..3046c09a4a --- /dev/null +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + Row, + RowSelectionState, + useReactTable, +} from "@tanstack/react-table"; +import { ChevronLeft } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; + +import { resolveFindingIds } from "@/actions/findings/findings-by-resource"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; +import { useInfiniteResources } from "@/hooks/use-infinite-resources"; +import { cn, hasDateOrScanFilter } from "@/lib"; +import { FindingGroupRow, FindingResourceRow } from "@/types"; + +import { FloatingMuteButton } from "../floating-mute-button"; +import { getColumnFindingResources } from "./column-finding-resources"; +import { FindingsSelectionContext } from "./findings-selection-context"; +import { ImpactedResourcesCell } from "./impacted-resources-cell"; +import { DeltaValues, NotificationIndicator } from "./notification-indicator"; +import { + ResourceDetailDrawer, + useResourceDetailDrawer, +} from "./resource-detail-drawer"; + +interface FindingsGroupDrillDownProps { + group: FindingGroupRow; + onCollapse: () => void; +} + +export function FindingsGroupDrillDown({ + group, + onCollapse, +}: FindingsGroupDrillDownProps) { + const searchParams = useSearchParams(); + const [rowSelection, setRowSelection] = useState({}); + const [resources, setResources] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Derive hasDateOrScan from current URL params + const currentParams = Object.fromEntries(searchParams.entries()); + const hasDateOrScan = hasDateOrScanFilter(currentParams); + + // Extract filter params from search params + const filters: Record = {}; + searchParams.forEach((value, key) => { + if (key.startsWith("filter[") || key.includes("__in")) { + filters[key] = value; + } + }); + + const handleSetResources = ( + newResources: FindingResourceRow[], + _hasMore: boolean, + ) => { + setResources(newResources); + setIsLoading(false); + }; + + const handleAppendResources = ( + newResources: FindingResourceRow[], + _hasMore: boolean, + ) => { + setResources((prev) => [...prev, ...newResources]); + setIsLoading(false); + }; + + const handleSetLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + const { sentinelRef, refresh, loadMore } = useInfiniteResources({ + checkId: group.checkId, + hasDateOrScanFilter: hasDateOrScan, + filters, + onSetResources: handleSetResources, + onAppendResources: handleAppendResources, + onSetLoading: handleSetLoading, + }); + + // Resource detail drawer + const drawer = useResourceDetailDrawer({ + resources, + checkId: group.checkId, + totalResourceCount: group.resourcesTotal, + onRequestMoreResources: loadMore, + }); + + const handleDrawerMuteComplete = () => { + drawer.refetchCurrent(); + refresh(); + }; + + // Selection logic — tracks by findingId (resource_id) for checkbox consistency + const selectedFindingIds = Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .map((idx) => resources[parseInt(idx)]?.findingId) + .filter(Boolean); + + /** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */ + const resolveResourceIds = async (ids: string[]) => { + const resourceUids = ids + .map((id) => resources.find((r) => r.findingId === id)?.resourceUid) + .filter(Boolean) as string[]; + if (resourceUids.length === 0) return []; + return resolveFindingIds({ + checkId: group.checkId, + resourceUids, + filters, + hasDateOrScanFilter: hasDateOrScan, + }); + }; + + const selectableRowCount = resources.filter((r) => !r.isMuted).length; + + const getRowCanSelect = (row: Row): boolean => { + return !row.original.isMuted; + }; + + const clearSelection = () => { + setRowSelection({}); + }; + + const isSelected = (id: string) => { + return selectedFindingIds.includes(id); + }; + + const handleMuteComplete = () => { + clearSelection(); + refresh(); + }; + + const columns = getColumnFindingResources({ + rowSelection, + selectableRowCount, + }); + + const table = useReactTable({ + data: resources, + columns, + enableRowSelection: getRowCanSelect, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: setRowSelection, + manualPagination: true, + state: { + rowSelection, + }, + }); + + // Delta for the sticky header + const delta = + group.newCount > 0 + ? DeltaValues.NEW + : group.changedCount > 0 + ? DeltaValues.CHANGED + : DeltaValues.NONE; + + const allMuted = + group.mutedCount > 0 && group.mutedCount === group.resourcesTotal; + + const rows = table.getRowModel().rows; + + return ( + +
+ {/* Sticky header — expanded finding group summary */} +
+
+ {/* Back button */} + + + {/* Notification indicator */} + + + {/* Status badge */} + + + {/* Finding title */} +
+

+ {group.checkTitle} +

+
+ + {/* Severity */} + + + {/* Impacted resources count */} + +
+
+ + {/* Resources table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {rows?.length ? ( + rows.map((row) => ( + drawer.openDrawer(row.index)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : !isLoading ? ( + + + No resources found. + + + ) : null} + +
+ + {/* Loading indicator */} + {isLoading && ( +
+ + + Loading resources... + +
+ )} + + {/* Sentinel for infinite scroll */} +
+
+
+ + {selectedFindingIds.length > 0 && ( + { + return resolveResourceIds(selectedFindingIds); + }} + onComplete={handleMuteComplete} + isBulkOperation + /> + )} + + { + if (!open) drawer.closeDrawer(); + }} + isLoading={drawer.isLoading} + isNavigating={drawer.isNavigating} + checkMeta={drawer.checkMeta} + currentIndex={drawer.currentIndex} + totalResources={drawer.totalResources} + currentFinding={drawer.currentFinding} + otherFindings={drawer.otherFindings} + onNavigatePrev={drawer.navigatePrev} + onNavigateNext={drawer.navigateNext} + onMuteComplete={handleDrawerMuteComplete} + /> + + ); +} diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx new file mode 100644 index 0000000000..6c2920d23b --- /dev/null +++ b/ui/components/findings/table/findings-group-table.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { Row, RowSelectionState } from "@tanstack/react-table"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useRef, useState } from "react"; + +import { + resolveFindingIds, + resolveFindingIdsByVisibleGroupResources, +} from "@/actions/findings/findings-by-resource"; +import { DataTable } from "@/components/ui/table"; +import { hasDateOrScanFilter } from "@/lib"; +import { FindingGroupRow, MetaDataProps } from "@/types"; + +import { FloatingMuteButton } from "../floating-mute-button"; +import { getColumnFindingGroups } from "./column-finding-groups"; +import { FindingsSelectionContext } from "./findings-selection-context"; +import { + InlineResourceContainer, + InlineResourceContainerHandle, +} from "./inline-resource-container"; + +function buildMuteLabel(groupCount: number, resourceCount: number): string { + const parts: string[] = []; + if (groupCount > 0) { + parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`); + } + if (resourceCount > 0) { + parts.push( + `${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`, + ); + } + return `Mute ${parts.join(" and ")}`; +} + +interface FindingsGroupTableProps { + data: FindingGroupRow[]; + metadata?: MetaDataProps; +} + +export function FindingsGroupTable({ + data, + metadata, +}: FindingsGroupTableProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [rowSelection, setRowSelection] = useState({}); + const [expandedCheckId, setExpandedCheckId] = useState(null); + const [expandedGroup, setExpandedGroup] = useState( + null, + ); + const [resourceSearch, setResourceSearch] = useState(""); + const [resourceSelection, setResourceSelection] = useState([]); + const inlineRef = useRef(null); + + // State resets (selection, drill-down) are handled by the parent via + // key={groupKey} — when data changes, the component remounts with fresh state. + + const safeData = data ?? []; + const hasResourceSelection = resourceSelection.length > 0; + const currentParams = Object.fromEntries(searchParams.entries()); + const hasDateOrScan = hasDateOrScanFilter(currentParams); + + const filters: Record = {}; + searchParams.forEach((value, key) => { + if (key.startsWith("filter[")) { + filters[key] = value; + } + }); + + // Get selected group check IDs. When the expanded group has individual resource + // selections, exclude it from group-level mute targets — the resource-level + // FloatingMuteButton handles those. + const selectedCheckIds = Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .map((idx) => safeData[parseInt(idx)]?.checkId) + .filter(Boolean) + .filter( + (checkId) => !(hasResourceSelection && checkId === expandedCheckId), + ); + + const selectedFindings = Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .map((idx) => safeData[parseInt(idx)]) + .filter(Boolean); + + // Count of selectable rows (groups where not ALL findings are muted) + const selectableRowCount = safeData.filter( + (g) => !(g.mutedCount > 0 && g.mutedCount === g.resourcesTotal), + ).length; + + const getRowCanSelect = (row: Row): boolean => { + const group = row.original; + return !(group.mutedCount > 0 && group.mutedCount === group.resourcesTotal); + }; + + const clearSelection = () => { + setRowSelection({}); + }; + + const isSelected = (id: string) => { + return selectedCheckIds.includes(id); + }; + + const resolveGroupMuteIds = async (checkIds: string[]) => { + const results = await Promise.all( + checkIds.map((checkId) => + resolveFindingIdsByVisibleGroupResources({ + checkId, + filters, + hasDateOrScanFilter: hasDateOrScan, + resourceSearch: + checkId === expandedCheckId && resourceSearch + ? resourceSearch + : undefined, + }), + ), + ); + + return Array.from(new Set(results.flat())); + }; + + /** Shared resolver for group row action dropdowns (via context). */ + const resolveMuteIds = async (checkIds: string[]) => + resolveGroupMuteIds(checkIds); + + const handleMuteComplete = () => { + clearSelection(); + setResourceSelection([]); + inlineRef.current?.clearSelection(); + inlineRef.current?.refresh(); + router.refresh(); + }; + + const handleDrillDown = (checkId: string, group: FindingGroupRow) => { + // Toggle: same group = collapse, different = switch + if (expandedCheckId === checkId) { + handleCollapse(); + return; + } + setExpandedCheckId(checkId); + setExpandedGroup(group); + setResourceSearch(""); + setResourceSelection([]); + }; + + const handleCollapse = () => { + setExpandedCheckId(null); + setExpandedGroup(null); + setResourceSearch(""); + setResourceSelection([]); + }; + + const columns = getColumnFindingGroups({ + rowSelection, + selectableRowCount, + onDrillDown: handleDrillDown, + expandedCheckId, + hasResourceSelection, + }); + + const renderAfterRow = (row: Row) => { + const group = row.original; + if (group.checkId !== expandedCheckId || !expandedGroup) return null; + + return ( + + ); + }; + + return ( + + + + {(selectedCheckIds.length > 0 || hasResourceSelection) && ( + { + const [groupIds, resourceIds] = await Promise.all([ + selectedCheckIds.length > 0 + ? resolveGroupMuteIds(selectedCheckIds) + : Promise.resolve([]), + hasResourceSelection && expandedCheckId + ? resolveFindingIds({ + checkId: expandedCheckId, + resourceUids: resourceSelection, + filters, + hasDateOrScanFilter: hasDateOrScan, + }) + : Promise.resolve([]), + ]); + return [...groupIds, ...resourceIds]; + }} + onComplete={handleMuteComplete} + isBulkOperation={ + selectedCheckIds.length > 0 || resourceSelection.length > 1 + } + /> + )} + + ); +} diff --git a/ui/components/findings/table/findings-selection-context.tsx b/ui/components/findings/table/findings-selection-context.tsx index 224543d107..7ab48d3391 100644 --- a/ui/components/findings/table/findings-selection-context.tsx +++ b/ui/components/findings/table/findings-selection-context.tsx @@ -2,13 +2,17 @@ import { createContext, useContext } from "react"; -import { FindingProps } from "@/types"; +import { FindingGroupRow, FindingProps } from "@/types"; interface FindingsSelectionContextValue { selectedFindingIds: string[]; - selectedFindings: FindingProps[]; + selectedFindings: (FindingProps | FindingGroupRow)[]; clearSelection: () => void; isSelected: (id: string) => boolean; + /** Resolves display IDs (check_ids or resource_ids) into real finding UUIDs for the mute API. */ + resolveMuteIds?: (ids: string[]) => Promise; + /** Called after a mute operation completes to refresh data. */ + onMuteComplete?: () => void; } export const FindingsSelectionContext = diff --git a/ui/components/findings/table/findings-table-with-selection.tsx b/ui/components/findings/table/findings-table-with-selection.tsx deleted file mode 100644 index c3720758c9..0000000000 --- a/ui/components/findings/table/findings-table-with-selection.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { Row, RowSelectionState } from "@tanstack/react-table"; -import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; - -import { DataTable } from "@/components/ui/table"; -import { FindingProps, MetaDataProps } from "@/types"; - -import { FloatingMuteButton } from "../floating-mute-button"; -import { getColumnFindings } from "./column-findings"; -import { FindingsSelectionContext } from "./findings-selection-context"; - -interface FindingsTableWithSelectionProps { - data: FindingProps[]; - metadata?: MetaDataProps; -} - -export function FindingsTableWithSelection({ - data, - metadata, -}: FindingsTableWithSelectionProps) { - const router = useRouter(); - const [rowSelection, setRowSelection] = useState({}); - - // Track the finding IDs to detect when data changes (e.g., after muting) - const currentFindingIds = (data ?? []).map((f) => f.id).join(","); - const previousFindingIdsRef = useRef(currentFindingIds); - - // Reset selection when page changes - useEffect(() => { - setRowSelection({}); - }, [metadata?.pagination?.page]); - - // Reset selection when the data changes (e.g., after muting a finding) - // This prevents the wrong findings from appearing selected after refresh - useEffect(() => { - if (previousFindingIdsRef.current !== currentFindingIds) { - setRowSelection({}); - previousFindingIdsRef.current = currentFindingIds; - } - }, [currentFindingIds]); - - // Ensure data is always an array for safe operations - const safeData = data ?? []; - - // Get selected finding IDs and data (only non-muted findings can be selected) - const selectedFindingIds = Object.keys(rowSelection) - .filter((key) => rowSelection[key]) - .map((idx) => safeData[parseInt(idx)]?.id) - .filter(Boolean); - - const selectedFindings = Object.keys(rowSelection) - .filter((key) => rowSelection[key]) - .map((idx) => safeData[parseInt(idx)]) - .filter(Boolean); - - // Count of selectable rows (non-muted findings only) - const selectableRowCount = safeData.filter((f) => !f.attributes.muted).length; - - // Function to determine if a row can be selected (muted findings cannot be selected) - const getRowCanSelect = (row: Row): boolean => { - return !row.original.attributes.muted; - }; - - const clearSelection = () => { - setRowSelection({}); - }; - - const isSelected = (id: string) => { - return selectedFindingIds.includes(id); - }; - - // Handle mute completion: clear selection and refresh data - const handleMuteComplete = () => { - clearSelection(); - router.refresh(); - }; - - // Generate columns with access to rowSelection state and selectable row count - const columns = getColumnFindings(rowSelection, selectableRowCount); - - return ( - - - - {selectedFindingIds.length > 0 && ( - - )} - - ); -} diff --git a/ui/components/findings/table/impacted-providers-cell.tsx b/ui/components/findings/table/impacted-providers-cell.tsx new file mode 100644 index 0000000000..8ec1de8b4f --- /dev/null +++ b/ui/components/findings/table/impacted-providers-cell.tsx @@ -0,0 +1,52 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { ProviderType } from "@/types"; + +import { ProviderIconCell } from "./provider-icon-cell"; + +const MAX_VISIBLE_PROVIDERS = 3; + +interface ImpactedProvidersCellProps { + providers: ProviderType[]; +} + +export const ImpactedProvidersCell = ({ + providers, +}: ImpactedProvidersCellProps) => { + if (!providers.length) { + return -; + } + + const visible = providers.slice(0, MAX_VISIBLE_PROVIDERS); + const remaining = providers.length - MAX_VISIBLE_PROVIDERS; + + return ( +
+ {visible.map((provider) => ( + + ))} + {remaining > 0 && ( + + + + +{remaining} + + + + + {providers.slice(MAX_VISIBLE_PROVIDERS).join(", ")} + + + + )} +
+ ); +}; diff --git a/ui/components/findings/table/impacted-resources-cell.tsx b/ui/components/findings/table/impacted-resources-cell.tsx index 8ad9ccd530..a2611be45d 100644 --- a/ui/components/findings/table/impacted-resources-cell.tsx +++ b/ui/components/findings/table/impacted-resources-cell.tsx @@ -1,6 +1,6 @@ import { Check, Flag } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { Badge } from "@/components/shadcn"; export const TriageStatusValues = { IN_PROGRESS: "in_progress", @@ -24,12 +24,7 @@ const TriageBadge = ({ status, count }: TriageBadgeProps) => { const isInProgress = status === TriageStatusValues.IN_PROGRESS; return ( - + {isInProgress ? ( ) : ( @@ -39,7 +34,7 @@ const TriageBadge = ({ status, count }: TriageBadgeProps) => { {isInProgress ? "In-progress" : "Resolved"} - + ); }; @@ -58,16 +53,11 @@ export const ImpactedResourcesCell = ({ }: ImpactedResourcesCellProps) => { return (
- + {impacted} of {total} - + {inProgress > 0 && ( void; + /** Clear internal row selection and notify parent. */ + clearSelection: () => void; +} + +interface InlineResourceContainerProps { + group: FindingGroupRow; + resourceSearch: string; + columnCount: number; + /** Called with selected resource UIDs (not finding IDs) for parent-level mute resolution */ + onResourceSelectionChange: (resourceUids: string[]) => void; + ref?: React.Ref; +} + +// NOTE: We intentionally do NOT auto-select child resources when a parent group +// is selected. Group-level mute resolution now fetches the group's visible +// resources separately. Auto-selecting children would still require syncing state +// with infinite scroll (resources load 10 at a time), causing cascading setState +// during render and confusing partial selections. Resource-level checkboxes are +// for selecting a specific subset independently. + +/** Max skeleton rows that fit in the 440px scroll container */ +const MAX_SKELETON_ROWS = 7; + +function ResourceSkeletonRow() { + return ( + + {/* Select: indicator + corner arrow + checkbox */} + +
+ + +
+
+ + {/* Resource: icon + name + uid */} + +
+ +
+ + +
+
+
+ {/* Status */} + + + + {/* Service */} + + + + {/* Region */} + + + + {/* Severity */} + +
+ + +
+
+ {/* Account: provider icon + alias + uid */} + +
+ +
+ + +
+
+
+ {/* Last seen */} + + + + {/* Failing for */} + + + + {/* Actions */} + + + + + ); +} + +export function InlineResourceContainer({ + group, + resourceSearch, + columnCount, + onResourceSelectionChange, + ref, +}: InlineResourceContainerProps) { + const searchParams = useSearchParams(); + const scrollContainerRef = useRef(null); + const [rowSelection, setRowSelection] = useState({}); + const [resources, setResources] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Scroll hint: shows "scroll for more" when content overflows + const { + containerRef: scrollHintContainerRef, + sentinelRef: scrollHintSentinelRef, + showScrollHint, + } = useScrollHint({ refreshToken: resources.length }); + + // Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef + const combinedScrollRef = (node: HTMLDivElement | null) => { + scrollContainerRef.current = node; + scrollHintContainerRef(node); + }; + + // Derive hasDateOrScan from current URL params + const currentParams = Object.fromEntries(searchParams.entries()); + const hasDateOrScan = hasDateOrScanFilter(currentParams); + + // Extract filter params from search params, merge with local resource search + const filters: Record = {}; + searchParams.forEach((value, key) => { + if (key.startsWith("filter[") || key.includes("__in")) { + filters[key] = value; + } + }); + if (resourceSearch) { + filters["filter[name__icontains]"] = resourceSearch; + } + + const handleSetResources = ( + newResources: FindingResourceRow[], + _hasMore: boolean, + ) => { + setResources(newResources); + setIsLoading(false); + }; + + const handleAppendResources = ( + newResources: FindingResourceRow[], + _hasMore: boolean, + ) => { + setResources((prev) => [...prev, ...newResources]); + setIsLoading(false); + }; + + const handleSetLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + const { sentinelRef, refresh, loadMore } = useInfiniteResources({ + checkId: group.checkId, + hasDateOrScanFilter: hasDateOrScan, + filters, + onSetResources: handleSetResources, + onAppendResources: handleAppendResources, + onSetLoading: handleSetLoading, + scrollContainerRef, + }); + + // Resource detail drawer + const drawer = useResourceDetailDrawer({ + resources, + checkId: group.checkId, + totalResourceCount: group.resourcesTotal, + onRequestMoreResources: loadMore, + }); + + const handleDrawerMuteComplete = () => { + drawer.refetchCurrent(); + refresh(); + }; + + // Selection logic + const selectedFindingIds = Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .map((idx) => resources[parseInt(idx)]?.findingId) + .filter(Boolean); + + const resolveResourceIds = async (ids: string[]) => { + const resourceUids = ids + .map((id) => resources.find((r) => r.findingId === id)?.resourceUid) + .filter(Boolean) as string[]; + if (resourceUids.length === 0) return []; + return resolveFindingIds({ + checkId: group.checkId, + resourceUids, + filters, + hasDateOrScanFilter: hasDateOrScan, + }); + }; + + const selectableRowCount = resources.filter((r) => !r.isMuted).length; + + const getRowCanSelect = (row: Row): boolean => { + return !row.original.isMuted; + }; + + const clearSelection = () => { + setRowSelection({}); + onResourceSelectionChange([]); + }; + + useImperativeHandle(ref, () => ({ refresh, clearSelection })); + + const isSelected = (id: string) => { + return selectedFindingIds.includes(id); + }; + + const handleMuteComplete = () => { + clearSelection(); + refresh(); + }; + + const handleRowSelectionChange = ( + updater: + | RowSelectionState + | ((prev: RowSelectionState) => RowSelectionState), + ) => { + const newSelection = + typeof updater === "function" ? updater(rowSelection) : updater; + setRowSelection(newSelection); + + const newResourceUids = Object.keys(newSelection) + .filter((key) => newSelection[key]) + .map((idx) => resources[parseInt(idx)]?.resourceUid) + .filter(Boolean); + onResourceSelectionChange(newResourceUids); + }; + + const columns = getColumnFindingResources({ + rowSelection, + selectableRowCount, + }); + + const table = useReactTable({ + data: resources, + columns, + enableRowSelection: getRowCanSelect, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: handleRowSelectionChange, + manualPagination: true, + state: { + rowSelection, + }, + }); + + const rows = table.getRowModel().rows; + + return ( + + + + + +
+
+ {/* Resource rows or skeleton placeholder */} + + + {isLoading && rows.length === 0 ? ( + Array.from({ + length: Math.min( + group.resourcesTotal, + MAX_SKELETON_ROWS, + ), + }).map((_, i) => ) + ) : rows.length > 0 ? ( + rows.map((row) => ( + drawer.openDrawer(row.index)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No resources found. + + + )} + +
+ + {/* Spinner for infinite scroll (subsequent pages only) */} + {isLoading && rows.length > 0 && ( +
+ + + Loading resources... + +
+ )} + + {/* Sentinel for scroll hint detection */} +
+ + {/* Sentinel for infinite scroll */} +
+
+ + {/* Gradients rendered after scroll container so they paint on top */} +
+
+ + {/* Scroll hint */} + {showScrollHint && ( +
+
+
+ Scroll for + more +
+
+
+ )} +
+ + + + + + {createPortal( + { + if (!open) drawer.closeDrawer(); + }} + isLoading={drawer.isLoading} + isNavigating={drawer.isNavigating} + checkMeta={drawer.checkMeta} + currentIndex={drawer.currentIndex} + totalResources={drawer.totalResources} + currentFinding={drawer.currentFinding} + otherFindings={drawer.otherFindings} + onNavigatePrev={drawer.navigatePrev} + onNavigateNext={drawer.navigateNext} + onMuteComplete={handleDrawerMuteComplete} + />, + document.body, + )} + + ); +} diff --git a/ui/components/findings/table/notification-indicator.tsx b/ui/components/findings/table/notification-indicator.tsx index 4e040a784d..43b9188a81 100644 --- a/ui/components/findings/table/notification-indicator.tsx +++ b/ui/components/findings/table/notification-indicator.tsx @@ -31,22 +31,34 @@ export const NotificationIndicator = ({ }: NotificationIndicatorProps) => { // Muted takes precedence over delta if (isMuted) { - const ruleName = mutedReason || "Unknown rule"; - return ( -
+
e.stopPropagation()} + >
- + e.stopPropagation()}> e.stopPropagation()} className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4" > - Mute rule: - {ruleName} + {/* TODO: always show rule name once the API returns muted_reason in finding-group-resources */} + {mutedReason ? ( + <> + Mute rule: + {mutedReason} + + ) : ( + <> + Mute rule: + view rules + + )} @@ -59,13 +71,18 @@ export const NotificationIndicator = ({
+ onClick={(e) => e.stopPropagation()} + className="flex w-2 shrink-0 cursor-pointer items-center justify-center" + > +
+
@@ -96,5 +113,5 @@ export const NotificationIndicator = ({ } // No indicator - return minimal width placeholder - return
; + return
; }; diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index d56d82b5c9..46d4ba8004 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -14,6 +14,7 @@ import { OpenStackProviderBadge, OracleCloudProviderBadge, } from "@/components/icons/providers-badge"; +import { cn } from "@/lib/utils"; import { ProviderType } from "@/types"; export const PROVIDER_ICONS = { @@ -36,24 +37,31 @@ export const PROVIDER_ICONS = { interface ProviderIconCellProps { provider: ProviderType; size?: number; + className?: string; } export const ProviderIconCell = ({ provider, size = 26, + className = "size-8 rounded-md bg-white", }: ProviderIconCellProps) => { const IconComponent = PROVIDER_ICONS[provider]; if (!IconComponent) { return ( -
+
?
); } return ( -
+
); diff --git a/ui/components/findings/table/resource-detail-drawer/index.ts b/ui/components/findings/table/resource-detail-drawer/index.ts new file mode 100644 index 0000000000..a59d02e5d9 --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/index.ts @@ -0,0 +1,2 @@ +export { ResourceDetailDrawer } from "./resource-detail-drawer"; +export { useResourceDetailDrawer } from "./use-resource-detail-drawer"; diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx new file mode 100644 index 0000000000..e54839ea54 --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -0,0 +1,767 @@ +"use client"; + +import { + Box, + CircleArrowRight, + CircleChevronLeft, + CircleChevronRight, + Container, + ExternalLink, + VolumeOff, + VolumeX, +} from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useState } from "react"; + +import type { ResourceDrawerFinding } from "@/actions/findings"; +import { MarkdownContainer } from "@/components/findings/markdown-container"; +import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; +import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; +import { getComplianceIcon } from "@/components/icons"; +import { JiraIcon } from "@/components/icons/services/IconServices"; +import { + Badge, + Button, + InfoField, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn"; +import { Card } from "@/components/shadcn/card/card"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { DateWithTime } from "@/components/ui/entities/date-with-time"; +import { EntityInfo } from "@/components/ui/entities/entity-info"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { SeverityBadge } from "@/components/ui/table/severity-badge"; +import { + type FindingStatus, + StatusFindingBadge, +} from "@/components/ui/table/status-finding-badge"; +import { getFailingForLabel } from "@/lib/date-utils"; +import { formatDuration } from "@/lib/date-utils"; +import { getRegionFlag } from "@/lib/region-flags"; + +import { Muted } from "../../muted"; +import { DeltaIndicator } from "../delta-indicator"; +import { NotificationIndicator } from "../notification-indicator"; +import { ResourceDetailSkeleton } from "./resource-detail-skeleton"; +import type { CheckMeta } from "./use-resource-detail-drawer"; + +interface ResourceDetailDrawerContentProps { + isLoading: boolean; + isNavigating: boolean; + checkMeta: CheckMeta | null; + currentIndex: number; + totalResources: number; + currentFinding: ResourceDrawerFinding | null; + otherFindings: ResourceDrawerFinding[]; + onNavigatePrev: () => void; + onNavigateNext: () => void; + onMuteComplete: () => void; +} + +export function ResourceDetailDrawerContent({ + isLoading, + isNavigating, + checkMeta, + currentIndex, + totalResources, + currentFinding, + otherFindings, + onNavigatePrev, + onNavigateNext, + onMuteComplete, +}: ResourceDetailDrawerContentProps) { + const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); + + // Initial load — no check metadata yet + if (!checkMeta && isLoading) { + return ( +
+ {/* Header skeleton */} +
+
+ + +
+ +
+ {/* Navigation skeleton */} +
+ +
+ + +
+
+ {/* Resource card skeleton */} +
+ +
+
+ ); + } + + if (!checkMeta) { + return ( +
+

+ No finding data available for this resource. +

+
+ ); + } + + // checkMeta is always available from here. + // currentFinding may be null during resource loading (e.g. drawer reopen). + const f = currentFinding; + const hasPrev = currentIndex > 0; + const hasNext = currentIndex < totalResources - 1; + + return ( +
+ {/* Mute modal — rendered outside drawer content to avoid overlay conflicts */} + {f && !f.isMuted && ( + { + setIsMuteModalOpen(false); + onMuteComplete(); + }} + /> + )} + {f && ( + + )} + + {/* Header: status badges + title (check-level from checkMeta) */} +
+
+ {f && } + {f && } + {f?.delta && ( +
+ + + {f.delta} + +
+ )} + {f && ( + + )} +
+ +

+ {checkMeta.checkTitle} +

+ + {checkMeta.complianceFrameworks.length > 0 && ( +
+ + Compliance Frameworks: + +
+ {checkMeta.complianceFrameworks.map((framework) => { + const icon = getComplianceIcon(framework); + return icon ? ( + + +
+ {framework} +
+
+ {framework} +
+ ) : ( + + + + {framework} + + + {framework} + + ); + })} +
+
+ )} +
+ + {/* Navigation: "Impacted Resource (X of N)" */} +
+ + Impacted Resource + {currentIndex + 1} + of + {totalResources} + +
+ + +
+
+ + {/* Resource card */} +
+ {/* Resource info — shows loading when currentFinding is not yet available */} + {!f || isNavigating ? ( + + ) : ( + <> +
+ {/* Resource info grid — 4 data columns */} +
+ {/* Row 1: Account, Resource, Service, Region */} + } + entityAlias={f.providerAlias} + entityId={f.providerUid} + /> + } + entityAlias={f.resourceGroup} + entityId={f.resourceUid} + idLabel="UID" + /> + + {f.resourceService} + + + + {getRegionFlag(f.resourceRegion) && ( + + {getRegionFlag(f.resourceRegion)} + + )} + {f.resourceRegion} + + + + {/* Row 2: Dates */} + + + + + + + + {getFailingForLabel(f.firstSeenAt) || "-"} + +
+ + {/* Row 3: IDs */} + + + + + + + + + +
+ + {/* Actions button — fixed size, aligned with row 1 */} +
+ + + ) : ( + + ) + } + label={f.isMuted ? "Muted" : "Mute"} + disabled={f.isMuted} + onSelect={() => setIsMuteModalOpen(true)} + /> + } + label="Send to Jira" + onSelect={() => setIsJiraModalOpen(true)} + /> + +
+
+ + )} + + {/* Tabs */} + +
+ + Finding Overview + + Other Findings For This Resource + + Scans + Events + +
+ + {/* Finding Overview — check-level data from checkMeta (always stable) */} + + {/* Card 1: Risk + Description + Status Extended */} + {(checkMeta.risk || checkMeta.description || f?.statusExtended) && ( + + {checkMeta.risk && ( +
+ + Risk: + + {checkMeta.risk} +
+ )} + {checkMeta.description && ( +
+ + Description: + + + {checkMeta.description} + +
+ )} + {f?.statusExtended && ( +
+ + Status Extended: + +

+ {f.statusExtended} +

+
+ )} +
+ )} + + {/* Card 2: Remediation + Commands */} + {(checkMeta.remediation.recommendation.text || + checkMeta.remediation.code.cli || + checkMeta.remediation.code.terraform || + checkMeta.remediation.code.nativeiac) && ( + + {checkMeta.remediation.recommendation.text && ( +
+ + Remediation: + +
+
+ + {checkMeta.remediation.recommendation.text} + +
+ {checkMeta.remediation.recommendation.url && ( + + View in Prowler Hub + + )} +
+
+ )} + + {checkMeta.remediation.code.cli && ( +
+ + CLI Command: + + +
+ )} + + {checkMeta.remediation.code.terraform && ( +
+ + Terraform Command: + + +
+ )} + + {checkMeta.remediation.code.nativeiac && ( +
+ + CloudFormation Command: + + +
+ )} + + {checkMeta.remediation.code.other && ( +
+ + Remediation Steps: + + + {checkMeta.remediation.code.other} + +
+ )} +
+ )} + + {checkMeta.additionalUrls.length > 0 && ( + +
+ + References: + +
    + {checkMeta.additionalUrls.map((link, idx) => ( +
  • + + {link} + +
  • + ))} +
+
+
+ )} + + {checkMeta.categories.length > 0 && ( + +
+ + Categories: + +

+ {checkMeta.categories.join(", ")} +

+
+
+ )} +
+ + {/* Other Findings For This Resource */} + + {!f || isNavigating ? ( +
+ +
+ ) : ( + <> +
+

+ Failed Findings For This Resource +

+ + {otherFindings.length} Total Entries + +
+ + + + + + + + Status + + + + + Finding + + + + + Severity + + + + + Time + + + + + + + {otherFindings.length > 0 ? ( + otherFindings.map((finding) => ( + + )) + ) : ( + + + + No other findings for this resource. + + + + )} + +
+ + )} +
+ + {/* Scans Tab */} + + {f?.scan ? ( + <> +
+

+ Showing the latest scan that evaluated this finding +

+ +
+
+ + {f.scan.name || "N/A"} + + + {f.scan.uniqueResourceCount} + + + {f.scan.progress}% + +
+
+ + {f.scan.trigger} + + + {f.scan.state} + + + {formatDuration(f.scan.duration)} + +
+
+ + + + + + +
+
+ + + + {f.scan.scheduledAt && ( + + + + )} +
+ + ) : ( +

+ Scan information is not available. +

+ )} +
+ + {/* Events Tab */} + + + +
+
+ + {/* Lighthouse AI button */} + + + View This Finding With Lighthouse AI + +
+ ); +} + +function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) { + const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); + + const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`; + + return ( + <> + {!finding.isMuted && ( + + )} + + window.open(findingUrl, "_blank", "noopener,noreferrer")} + > + + + + + + + +

+ {finding.checkTitle} +

+
+ + + + + + + +
e.stopPropagation()}> + + + ) : ( + + ) + } + label={finding.isMuted ? "Muted" : "Mute"} + disabled={finding.isMuted} + onSelect={() => setIsMuteModalOpen(true)} + /> + } + label="Send to Jira" + onSelect={() => setIsJiraModalOpen(true)} + /> + +
+
+
+ + ); +} diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx new file mode 100644 index 0000000000..b8837ea596 --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { X } from "lucide-react"; + +import type { ResourceDrawerFinding } from "@/actions/findings"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/shadcn"; + +import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content"; +import type { CheckMeta } from "./use-resource-detail-drawer"; + +interface ResourceDetailDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + isLoading: boolean; + isNavigating: boolean; + checkMeta: CheckMeta | null; + currentIndex: number; + totalResources: number; + currentFinding: ResourceDrawerFinding | null; + otherFindings: ResourceDrawerFinding[]; + onNavigatePrev: () => void; + onNavigateNext: () => void; + onMuteComplete: () => void; +} + +export function ResourceDetailDrawer({ + open, + onOpenChange, + isLoading, + isNavigating, + checkMeta, + currentIndex, + totalResources, + currentFinding, + otherFindings, + onNavigatePrev, + onNavigateNext, + onMuteComplete, +}: ResourceDetailDrawerProps) { + return ( + + + + Resource Finding Details + + View finding details for the selected resource + + + + + Close + + {open && ( + + )} + + + ); +} diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx new file mode 100644 index 0000000000..ed123983f5 --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx @@ -0,0 +1,64 @@ +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; + +/** + * Skeleton placeholder for the resource info grid in the detail drawer. + * Mirrors the 4-column layout: EntityInfo × 2, InfoField × 2 per row, + * plus the actions button. + */ +export function ResourceDetailSkeleton() { + return ( +
+
+ {/* Row 1: Account, Resource, Service, Region */} + + + + + + {/* Row 2: Last detected, First seen, Failing for */} + + + +
+ + {/* Row 3: Check ID, Finding ID, Finding UID */} + + + +
+ + {/* Actions button */} + +
+ ); +} + +function EntityInfoSkeleton({ hasIcon = false }: { hasIcon?: boolean }) { + return ( +
+ {hasIcon && } +
+
+ + +
+ +
+
+ ); +} + +function InfoFieldSkeleton({ + labelWidth, + valueWidth, +}: { + labelWidth: string; + valueWidth: string; +}) { + return ( +
+ + +
+ ); +} diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts new file mode 100644 index 0000000000..bd39b3d63c --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -0,0 +1,213 @@ +"use client"; + +import { useRef, useState } from "react"; + +import { + adaptFindingsByResourceResponse, + getLatestFindingsByResourceUid, + type ResourceDrawerFinding, +} from "@/actions/findings"; +import { FindingResourceRow } from "@/types"; + +/** + * Check-level metadata that is identical across all resources for a given check. + * Extracted once on first successful fetch and kept stable during navigation. + */ +export interface CheckMeta { + checkId: string; + checkTitle: string; + risk: string; + description: string; + complianceFrameworks: string[]; + categories: string[]; + remediation: ResourceDrawerFinding["remediation"]; + additionalUrls: string[]; +} + +function extractCheckMeta(finding: ResourceDrawerFinding): CheckMeta { + return { + checkId: finding.checkId, + checkTitle: finding.checkTitle, + risk: finding.risk, + description: finding.description, + complianceFrameworks: finding.complianceFrameworks, + categories: finding.categories, + remediation: finding.remediation, + additionalUrls: finding.additionalUrls, + }; +} + +interface UseResourceDetailDrawerOptions { + resources: FindingResourceRow[]; + checkId: string; + totalResourceCount?: number; + onRequestMoreResources?: () => void; +} + +interface UseResourceDetailDrawerReturn { + isOpen: boolean; + isLoading: boolean; + isNavigating: boolean; + checkMeta: CheckMeta | null; + currentIndex: number; + totalResources: number; + currentFinding: ResourceDrawerFinding | null; + otherFindings: ResourceDrawerFinding[]; + allFindings: ResourceDrawerFinding[]; + openDrawer: (index: number) => void; + closeDrawer: () => void; + navigatePrev: () => void; + navigateNext: () => void; + /** Clear cache for current resource and re-fetch (e.g. after muting). */ + refetchCurrent: () => void; +} + +/** + * Manages the resource detail drawer state, fetching, and navigation. + * + * Caches findings per resourceUid in a Map ref so navigating prev/next + * doesn't re-fetch already-visited resources. + */ +export function useResourceDetailDrawer({ + resources, + checkId, + totalResourceCount, + onRequestMoreResources, +}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn { + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [findings, setFindings] = useState([]); + const [isNavigating, setIsNavigating] = useState(false); + + const cacheRef = useRef>(new Map()); + const checkMetaRef = useRef(null); + const fetchControllerRef = useRef(null); + + const fetchFindings = async (resourceUid: string) => { + // Abort any in-flight request to prevent stale data from out-of-order responses + fetchControllerRef.current?.abort(); + const controller = new AbortController(); + fetchControllerRef.current = controller; + + // Check cache first + const cached = cacheRef.current.get(resourceUid); + if (cached) { + if (!checkMetaRef.current) { + const main = cached.find((f) => f.checkId === checkId) ?? cached[0]; + if (main) checkMetaRef.current = extractCheckMeta(main); + } + setFindings(cached); + setIsLoading(false); + setIsNavigating(false); + return; + } + + setIsLoading(true); + try { + const response = await getLatestFindingsByResourceUid({ resourceUid }); + + // Discard stale response if a newer request was started + if (controller.signal.aborted) return; + + const adapted = adaptFindingsByResourceResponse(response); + cacheRef.current.set(resourceUid, adapted); + + // Extract check-level metadata once (stable across all resources) + if (!checkMetaRef.current) { + const main = adapted.find((f) => f.checkId === checkId) ?? adapted[0]; + if (main) checkMetaRef.current = extractCheckMeta(main); + } + + setFindings(adapted); + } catch (error) { + if (!controller.signal.aborted) { + console.error("Error fetching findings for resource:", error); + // Don't clear findings — keep previous data as fallback during navigation + } + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + setIsNavigating(false); + } + } + }; + + const openDrawer = (index: number) => { + const resource = resources[index]; + if (!resource) return; + + setCurrentIndex(index); + setIsOpen(true); + setFindings([]); + fetchFindings(resource.resourceUid); + }; + + const closeDrawer = () => { + setIsOpen(false); + }; + + const refetchCurrent = () => { + const resource = resources[currentIndex]; + if (!resource) return; + cacheRef.current.delete(resource.resourceUid); + setIsNavigating(true); + fetchFindings(resource.resourceUid); + }; + + const navigateTo = (index: number) => { + const resource = resources[index]; + if (!resource) return; + + setCurrentIndex(index); + setIsNavigating(true); + fetchFindings(resource.resourceUid); + }; + + const navigatePrev = () => { + if (currentIndex > 0) { + navigateTo(currentIndex - 1); + } + }; + + const navigateNext = () => { + const total = totalResourceCount ?? resources.length; + if (currentIndex >= total - 1) return; + + // Pre-fetch more resources when nearing the end of loaded data + if (currentIndex >= resources.length - 3) { + onRequestMoreResources?.(); + } + + // Navigate if the next resource is already loaded + if (currentIndex < resources.length - 1) { + navigateTo(currentIndex + 1); + } + }; + + // The finding whose checkId matches the drill-down's checkId + const currentFinding = + findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null; + + // All other findings for this resource + const otherFindings = currentFinding + ? findings.filter((f) => f.id !== currentFinding.id) + : findings; + + return { + isOpen, + isLoading, + isNavigating, + checkMeta: checkMetaRef.current, + currentIndex, + totalResources: totalResourceCount ?? resources.length, + currentFinding, + otherFindings, + allFindings: findings, + openDrawer, + closeDrawer, + navigatePrev, + navigateNext, + refetchCurrent, + }; +} diff --git a/ui/components/findings/table/skeleton-table-findings.tsx b/ui/components/findings/table/skeleton-table-findings.tsx index d7eccad760..79654d554c 100644 --- a/ui/components/findings/table/skeleton-table-findings.tsx +++ b/ui/components/findings/table/skeleton-table-findings.tsx @@ -9,6 +9,10 @@ const SkeletonTableRow = () => {
+ {/* Expand chevron */} + + + {/* Checkbox */}
@@ -24,14 +28,6 @@ const SkeletonTableRow = () => {
- {/* Resource name chip */} - -
- - - -
- {/* Severity */}
@@ -39,21 +35,17 @@ const SkeletonTableRow = () => {
- {/* Provider icon */} + {/* Provider icons */} - - - {/* Service */} - - - - {/* Time */} - -
- - +
+ +
+ {/* Resources badge */} + + + {/* Actions */} @@ -81,6 +73,8 @@ export const SkeletonTableFindings = () => { {/* Notification - empty header */} + {/* Expand - empty header */} + {/* Checkbox */}
@@ -93,25 +87,17 @@ export const SkeletonTableFindings = () => { - {/* Resource name */} - - - {/* Severity */} - {/* Provider */} + {/* Providers */} - + - {/* Service */} + {/* Resources */} - - - {/* Time */} - - + {/* Actions - empty header */} diff --git a/ui/components/invitations/table/data-table-row-actions.tsx b/ui/components/invitations/table/data-table-row-actions.tsx index b643202f80..a0ef693279 100644 --- a/ui/components/invitations/table/data-table-row-actions.tsx +++ b/ui/components/invitations/table/data-table-row-actions.tsx @@ -5,8 +5,6 @@ import { Eye, Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { VerticalDotsIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownDangerZone, @@ -59,13 +57,7 @@ export function DataTableRowActions({
- - - - } - > + } label="Check Details" diff --git a/ui/components/lighthouse/chat.tsx b/ui/components/lighthouse/chat.tsx index fbe4036d15..6a8622df1b 100644 --- a/ui/components/lighthouse/chat.tsx +++ b/ui/components/lighthouse/chat.tsx @@ -3,7 +3,7 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Plus } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse"; import { @@ -37,6 +37,7 @@ import { } from "@/components/shadcn"; import { useToast } from "@/components/ui"; import { CustomLink } from "@/components/ui/custom/custom-link"; +import { useMountEffect } from "@/hooks/use-mount-effect"; import type { LighthouseProvider } from "@/types/lighthouse"; interface Model { @@ -61,6 +62,7 @@ interface ChatProps { providers: Provider[]; defaultProviderId?: LighthouseProvider; defaultModelId?: string; + initialPrompt?: string; } interface SelectedModel { @@ -102,6 +104,7 @@ export const Chat = ({ providers: initialProviders, defaultProviderId, defaultModelId, + initialPrompt, }: ChatProps) => { const { toast } = useToast(); @@ -143,12 +146,11 @@ export const Chat = ({ selectedModelRef.current = selectedModel; // Load models for all providers on mount - useEffect(() => { + useMountEffect(() => { initialProviders.forEach((provider) => { loadModelsForProvider(provider.id); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); // Load all models for a specific provider const loadModelsForProvider = async (providerType: LighthouseProvider) => { @@ -306,6 +308,15 @@ export const Chat = ({ } }; + // Auto-send initial prompt from URL (e.g., finding context from drawer) + const initialPromptSentRef = useRef(false); + useMountEffect(() => { + if (initialPrompt && !initialPromptSentRef.current) { + initialPromptSentRef.current = true; + sendMessage({ text: initialPrompt }); + } + }); + // Handlers const handleNewChat = () => { setMessages([]); diff --git a/ui/components/manage-groups/table/data-table-row-actions.tsx b/ui/components/manage-groups/table/data-table-row-actions.tsx index c737e80604..6787897450 100644 --- a/ui/components/manage-groups/table/data-table-row-actions.tsx +++ b/ui/components/manage-groups/table/data-table-row-actions.tsx @@ -5,8 +5,6 @@ import { Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { VerticalDotsIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownDangerZone, @@ -40,13 +38,7 @@ export function DataTableRowActions({
- - - - } - > + } label="Edit Account Group" diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx index bcc8febee9..535d4186ba 100644 --- a/ui/components/providers/organizations/org-launch-scan.tsx +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -17,7 +17,7 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn/select/select"; -import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; import { ToastAction, useToast } from "@/components/ui"; import { useOrgSetupStore } from "@/store/organizations/store"; @@ -121,7 +121,7 @@ export function OrgLaunchScan({ {isLaunching ? (
- +

Launching scans...

diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx index 3c480e3c48..fe6d086d9f 100644 --- a/ui/components/providers/organizations/org-setup-form.tsx +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -22,7 +22,7 @@ import { WizardInputField } from "@/components/providers/workflow/forms/fields"; import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { Button } from "@/components/shadcn/button/button"; import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; -import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { useToast } from "@/components/ui"; import { Form } from "@/components/ui/form"; import { @@ -284,7 +284,7 @@ export function OrgSetupForm({ {setupPhase === ORG_SETUP_PHASE.ACCESS && isSubmitting && (
- +

Gathering AWS Accounts...

diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index bf9f1c5dd5..65d7901f8e 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -6,13 +6,11 @@ import { useState } from "react"; import { updateOrganizationName } from "@/actions/organizations/organizations"; import { updateProvider } from "@/actions/providers"; -import { VerticalDotsIcon } from "@/components/icons"; import { ProviderWizardModal } from "@/components/providers/wizard"; import { ORG_WIZARD_INTENT, OrgWizardInitialData, } from "@/components/providers/wizard/types"; -import { Button } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownDangerZone, @@ -150,13 +148,7 @@ function OrgGroupDropdownActions({
- - - - } - > + {isOrgKind && ( <> - - - - } - > + } label={loading ? "Testing..." : `Test Connection${bulkCount}`} @@ -399,13 +385,7 @@ export function DataTableRowActions({ />
- - - - } - > + } label="Edit Provider Alias" diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index c56ca167f7..bbefb717d8 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -11,7 +11,7 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn/select/select"; -import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; import { ToastAction, useToast } from "@/components/ui"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; @@ -111,7 +111,7 @@ export function LaunchStep({ return (
- +

Launching scans...

diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index cdbc000ff3..f37e8cc5c9 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { AlertTriangle, Eye, MoreVertical } from "lucide-react"; +import { AlertTriangle, Eye } from "lucide-react"; import { useState } from "react"; import { @@ -87,18 +87,7 @@ const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => { return ( <>
- - - - } - ariaLabel="Resource actions" - > + } label="View Details" diff --git a/ui/components/roles/table/data-table-row-actions.tsx b/ui/components/roles/table/data-table-row-actions.tsx index 70895817e3..d34ac429bc 100644 --- a/ui/components/roles/table/data-table-row-actions.tsx +++ b/ui/components/roles/table/data-table-row-actions.tsx @@ -5,8 +5,6 @@ import { Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { VerticalDotsIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownDangerZone, @@ -36,13 +34,7 @@ export function DataTableRowActions({
- - - - } - > + } label="Edit Role" diff --git a/ui/components/scans/table/scan-detail.tsx b/ui/components/scans/table/scan-detail.tsx index 6c650271a3..493d6beefc 100644 --- a/ui/components/scans/table/scan-detail.tsx +++ b/ui/components/scans/table/scan-detail.tsx @@ -4,26 +4,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { DateWithTime, EntityInfo, InfoField } from "@/components/ui/entities"; import { StatusBadge } from "@/components/ui/table/status-badge"; +import { formatDuration } from "@/lib/date-utils"; import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types"; const renderValue = (value: string | null | undefined) => { return value && value.trim() !== "" ? value : "-"; }; -const formatDuration = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - const parts = []; - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (remainingSeconds > 0 || parts.length === 0) - parts.push(`${remainingSeconds}s`); - - return parts.join(" "); -}; - export const ScanDetail = ({ scanDetails, }: { diff --git a/ui/components/scans/table/scans/data-table-row-actions.tsx b/ui/components/scans/table/scans/data-table-row-actions.tsx index e185d0d43d..a9e0cae118 100644 --- a/ui/components/scans/table/scans/data-table-row-actions.tsx +++ b/ui/components/scans/table/scans/data-table-row-actions.tsx @@ -4,8 +4,6 @@ import { Row } from "@tanstack/react-table"; import { Download, Pencil } from "lucide-react"; import { useState } from "react"; -import { VerticalDotsIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownItem, @@ -44,13 +42,7 @@ export function DataTableRowActions({
- - - - } - > + } label="Download .zip" diff --git a/ui/components/shadcn/checkbox/checkbox.tsx b/ui/components/shadcn/checkbox/checkbox.tsx index 110cf77ed1..6a577c18e2 100644 --- a/ui/components/shadcn/checkbox/checkbox.tsx +++ b/ui/components/shadcn/checkbox/checkbox.tsx @@ -61,7 +61,7 @@ function Checkbox({ data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none" > - {indeterminate ? ( + {indeterminate || checked === "indeterminate" ? ( ) : ( diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index 6d459a9d25..63721874a1 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -62,6 +62,8 @@ function DialogContent({ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", className, )} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} {...props} > {children} diff --git a/ui/components/shadcn/dropdown/action-dropdown.tsx b/ui/components/shadcn/dropdown/action-dropdown.tsx index 3eeb7dc385..db86d67c3d 100644 --- a/ui/components/shadcn/dropdown/action-dropdown.tsx +++ b/ui/components/shadcn/dropdown/action-dropdown.tsx @@ -1,6 +1,6 @@ "use client"; -import { MoreHorizontal } from "lucide-react"; +import { EllipsisVertical } from "lucide-react"; import { ComponentProps, ReactNode } from "react"; import { cn } from "@/lib/utils"; @@ -13,9 +13,19 @@ import { DropdownMenuTrigger, } from "./dropdown"; +const ACTION_TRIGGER_STYLES = { + table: "hover:bg-bg-neutral-tertiary rounded-full p-1 transition-colors", + bordered: + "hover:bg-bg-neutral-tertiary rounded-full border border-text-neutral-secondary p-2 transition-colors", +} as const; + +type ActionDropdownVariant = keyof typeof ACTION_TRIGGER_STYLES; + interface ActionDropdownProps { /** The dropdown trigger element. Defaults to a vertical dots icon button */ trigger?: ReactNode; + /** Trigger style variant. "table" = no border, "bordered" = circular border */ + variant?: ActionDropdownVariant; /** Alignment of the dropdown content */ align?: "start" | "center" | "end"; /** Additional className for the content */ @@ -27,6 +37,7 @@ interface ActionDropdownProps { export function ActionDropdown({ trigger, + variant = "table", align = "end", className, ariaLabel = "Open actions menu", @@ -39,9 +50,9 @@ export function ActionDropdown({ )} diff --git a/ui/components/shadcn/info-field/info-field.tsx b/ui/components/shadcn/info-field/info-field.tsx index d3bf0144ae..6df2cea10b 100644 --- a/ui/components/shadcn/info-field/info-field.tsx +++ b/ui/components/shadcn/info-field/info-field.tsx @@ -11,6 +11,7 @@ export const INFO_FIELD_VARIANTS = { default: "default", simple: "simple", transparent: "transparent", + compact: "compact", } as const; type InfoFieldVariant = @@ -61,17 +62,21 @@ export function InfoField({ ); } + const isCompact = variant === "compact"; + + const labelClassName = isCompact + ? "text-text-neutral-secondary text-[10px] whitespace-nowrap" + : "text-text-neutral-tertiary text-xs font-bold"; + return (
- - {labelContent} - + {labelContent} {variant === "simple" ? (
{children}
- ) : variant === "transparent" ? ( + ) : variant === "transparent" || variant === "compact" ? (
{children}
) : (
diff --git a/ui/components/shadcn/spinner/spinner.tsx b/ui/components/shadcn/spinner/spinner.tsx new file mode 100644 index 0000000000..7ded4c58c7 --- /dev/null +++ b/ui/components/shadcn/spinner/spinner.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface SpinnerProps { + className?: string; +} + +/** + * Spinner component - a circular loading indicator. + * + * Features: + * - 20x20 (size-5) default size + * - 2.5px stroke for good visibility + * - Uses button-primary color + * - Smooth rotation animation + * - Accepts className to override size (e.g. "size-6", "size-4") + */ +export function Spinner({ className }: SpinnerProps) { + return ( + + {/* Background track */} + + {/* Animated arc */} + + + ); +} diff --git a/ui/components/shadcn/tabs/tabs.constants.ts b/ui/components/shadcn/tabs/tabs.constants.ts deleted file mode 100644 index 4bac2eb71a..0000000000 --- a/ui/components/shadcn/tabs/tabs.constants.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Trigger component style parts using semantic class names - */ -const TRIGGER_STYLES = { - base: "relative inline-flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50", - border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]", - text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white", - active: - "data-[state=active]:text-slate-900 dark:data-[state=active]:text-white", - underline: - "after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-emerald-400 after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]", - focus: - "focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950", - icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", -} as const; - -/** - * Content component styles - */ -export const CONTENT_STYLES = - "mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const; - -/** - * Build trigger className by combining style parts - */ -export function buildTriggerClassName(): string { - return [ - TRIGGER_STYLES.base, - TRIGGER_STYLES.border, - TRIGGER_STYLES.text, - TRIGGER_STYLES.active, - TRIGGER_STYLES.underline, - TRIGGER_STYLES.focus, - TRIGGER_STYLES.icon, - ].join(" "); -} - -/** - * Build list className - */ -export function buildListClassName(): string { - return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]"; -} diff --git a/ui/components/shadcn/tabs/tabs.tsx b/ui/components/shadcn/tabs/tabs.tsx index 8208a7d292..fc593ca88d 100644 --- a/ui/components/shadcn/tabs/tabs.tsx +++ b/ui/components/shadcn/tabs/tabs.tsx @@ -5,11 +5,49 @@ import type { ComponentProps } from "react"; import { cn } from "@/lib/utils"; -import { - buildListClassName, - buildTriggerClassName, - CONTENT_STYLES, -} from "./tabs.constants"; +/** + * Trigger component style parts using semantic class names + */ +const TRIGGER_STYLES = { + base: "relative inline-flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4", + border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]", + text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white", + active: + "data-[state=active]:text-slate-900 dark:data-[state=active]:text-white", + underline: + "after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0", + focus: + "focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950", + icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", +} as const; + +/** + * Content component styles + */ +const CONTENT_STYLES = + "mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const; + +/** + * Build trigger className by combining style parts + */ +function buildTriggerClassName(): string { + return [ + TRIGGER_STYLES.base, + TRIGGER_STYLES.border, + TRIGGER_STYLES.text, + TRIGGER_STYLES.active, + TRIGGER_STYLES.underline, + TRIGGER_STYLES.focus, + TRIGGER_STYLES.icon, + ].join(" "); +} + +/** + * Build list className + */ +function buildListClassName(): string { + return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]"; +} function Tabs({ className, diff --git a/ui/components/shadcn/tree-view/tree-spinner.tsx b/ui/components/shadcn/tree-view/tree-spinner.tsx index 6527cb2ccb..ba2060c87d 100644 --- a/ui/components/shadcn/tree-view/tree-spinner.tsx +++ b/ui/components/shadcn/tree-view/tree-spinner.tsx @@ -1,50 +1,3 @@ -"use client"; - -import { cn } from "@/lib/utils"; - -interface TreeSpinnerProps { - className?: string; -} - -/** - * TreeSpinner component - a circular loading indicator for tree nodes. - * - * Features: - * - 20x20 (size-5) default size to match checkbox sm - * - 2.5px stroke for good visibility - * - Uses button-primary color - * - Smooth rotation animation - */ -export function TreeSpinner({ className }: TreeSpinnerProps) { - return ( - - {/* Background track */} - - {/* Animated arc */} - - - ); -} +// Re-export Spinner as TreeSpinner for tree-view internal usage. +// New code should import Spinner from "@/components/shadcn/spinner/spinner". +export { Spinner as TreeSpinner } from "../spinner/spinner"; diff --git a/ui/components/shared/events-timeline/events-timeline.tsx b/ui/components/shared/events-timeline/events-timeline.tsx index 415b699050..c4f248ed51 100644 --- a/ui/components/shared/events-timeline/events-timeline.tsx +++ b/ui/components/shared/events-timeline/events-timeline.tsx @@ -5,7 +5,6 @@ import { ChevronRight, Clock, Download, - Loader2, Server, Shield, } from "lucide-react"; @@ -21,6 +20,7 @@ import { Checkbox, InfoField, } from "@/components/shadcn"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { cn } from "@/lib/utils"; import { ResourceEventProps } from "@/types"; @@ -128,7 +128,7 @@ export const EventsTimeline = ({ if (isPending && !hasFetched) { return (
- +

Fetching CloudTrail events...

@@ -181,7 +181,7 @@ export const EventsTimeline = ({
- {isPending && } + {isPending && } {events.length} event{events.length !== 1 && "s"} diff --git a/ui/components/ui/code-snippet/code-snippet.tsx b/ui/components/ui/code-snippet/code-snippet.tsx index 9440b500e6..313787d48c 100644 --- a/ui/components/ui/code-snippet/code-snippet.tsx +++ b/ui/components/ui/code-snippet/code-snippet.tsx @@ -23,6 +23,8 @@ interface CodeSnippetProps { formatter?: (value: string) => string; /** Enable multiline display (disables truncation, enables word wrap) */ multiline?: boolean; + /** Remove background and border */ + transparent?: boolean; /** Custom aria-label for the copy button */ ariaLabel?: string; } @@ -34,6 +36,7 @@ export const CodeSnippet = ({ hideCopyButton = false, icon, formatter, + transparent = false, multiline = false, ariaLabel = "Copy to clipboard", }: CodeSnippetProps) => { @@ -86,8 +89,15 @@ export const CodeSnippet = ({ return (
diff --git a/ui/components/ui/entities/date-with-time.tsx b/ui/components/ui/entities/date-with-time.tsx index 817a170646..fd43fdbd5a 100644 --- a/ui/components/ui/entities/date-with-time.tsx +++ b/ui/components/ui/entities/date-with-time.tsx @@ -37,7 +37,9 @@ export const DateWithTime = ({
diff --git a/ui/components/ui/entities/entity-info.tsx b/ui/components/ui/entities/entity-info.tsx index 410cf9d086..e9a6627f31 100644 --- a/ui/components/ui/entities/entity-info.tsx +++ b/ui/components/ui/entities/entity-info.tsx @@ -15,9 +15,13 @@ import { getProviderLogo } from "./get-provider-logo"; interface EntityInfoProps { cloudProvider?: ProviderType; icon?: ReactNode; + /** Small icon rendered inline before the entity alias text */ + nameIcon?: ReactNode; entityAlias?: string; entityId?: string; badge?: string; + /** Label before the ID value. Defaults to "UID" */ + idLabel?: string; showCopyAction?: boolean; /** @deprecated No longer used — layout handles overflow naturally */ maxWidth?: string; @@ -30,9 +34,11 @@ interface EntityInfoProps { export const EntityInfo = ({ cloudProvider, icon, + nameIcon, entityAlias, entityId, badge, + idLabel = "UID", showCopyAction = true, }: EntityInfoProps) => { const canCopy = Boolean(entityId && showCopyAction); @@ -45,6 +51,11 @@ export const EntityInfo = ({ {renderedIcon &&
{renderedIcon}
}
+ {nameIcon && ( + + {nameIcon} + + )} @@ -64,7 +75,7 @@ export const EntityInfo = ({ {entityId && (
- UID: + {idLabel}: void; + placeholder?: string; + /** Badge shown inside the search input (e.g., active drill-down group title) */ + badge?: { label: string; onDismiss: () => void }; } export const DataTableSearch = ({ paramPrefix = "", controlledValue, onSearchChange, + placeholder = "Search...", + badge, }: DataTableSearchProps) => { const searchParams = useSearchParams(); const pathname = usePathname(); @@ -47,6 +58,9 @@ export const DataTableSearch = ({ // For display: use displayValue in controlled mode (for responsive typing), internalValue otherwise const value = isControlled ? displayValue : internalValue; + // Force expanded when badge is present + const hasBadge = !!badge; + // Sync displayValue when controlledValue changes externally (e.g., clear filters) useEffect(() => { if (isControlled) { @@ -58,8 +72,8 @@ export const DataTableSearch = ({ const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]"; const pageParam = paramPrefix ? `${paramPrefix}Page` : "page"; - // Keep expanded if there's a value or input is focused - const shouldStayExpanded = value.length > 0 || isFocused; + // Keep expanded if there's a value or input is focused or badge is present + const shouldStayExpanded = value.length > 0 || isFocused || hasBadge; // Sync with URL on mount (only for uncontrolled mode) useEffect(() => { @@ -152,7 +166,7 @@ export const DataTableSearch = ({ const handleBlur = () => { setIsFocused(false); - if (!value) { + if (!value && !hasBadge) { setIsExpanded(false); } }; @@ -165,11 +179,13 @@ export const DataTableSearch = ({ }, 50); }; + const effectiveExpanded = isExpanded || hasBadge; + return (
- {/* Expanded state - full input */} + {/* Expanded state - full input with optional badge */}
-
- -
- handleChange(e.target.value)} - onFocus={handleFocus} - onBlur={handleBlur} - className="border-border-neutral-tertiary bg-bg-neutral-tertiary focus:border-border-input-primary-pressed pr-9 pl-9 focus:ring-0 focus:ring-offset-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none" - /> - {isLoading && ( -
- +
+
+
- )} + + {hasBadge && ( + + + + {badge.label} + + + + {badge.label} + + )} + + handleChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + className="h-9 min-w-0 flex-1 border-0 bg-transparent pr-9 shadow-none hover:bg-transparent focus:border-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none" + /> + + {isLoading && ( +
+ +
+ )} +
); diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index 4548fc22b8..455f2805f0 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -16,7 +16,7 @@ import { useReactTable, } from "@tanstack/react-table"; import { AnimatePresence } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { Table, @@ -96,6 +96,12 @@ interface DataTableProviderProps { onPageSizeChange?: (pageSize: number) => void; /** Show loading state with opacity overlay (for controlled mode) */ isLoading?: boolean; + /** Custom placeholder text for the search input */ + searchPlaceholder?: string; + /** Render additional content after each row (e.g., inline expansion) */ + renderAfterRow?: (row: Row) => React.ReactNode; + /** Badge shown inside the search input (e.g., active drill-down group) */ + searchBadge?: { label: string; onDismiss: () => void }; } export function DataTable({ @@ -121,6 +127,9 @@ export function DataTable({ onPageChange, onPageSizeChange, isLoading = false, + searchPlaceholder, + renderAfterRow, + searchBadge, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -213,6 +222,8 @@ export function DataTable({ paramPrefix={paramPrefix} controlledValue={controlledSearch} onSearchChange={onSearchChange} + placeholder={searchPlaceholder} + badge={searchBadge} /> )}
@@ -262,19 +273,19 @@ export function DataTable({ isSomeSelected={row.getIsSomeSelected()} /> ) : ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - + + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + {renderAfterRow?.(row)} + ), ) ) : ( diff --git a/ui/components/ui/table/table.tsx b/ui/components/ui/table/table.tsx index c9bceda8ad..99e7f37588 100644 --- a/ui/components/ui/table/table.tsx +++ b/ui/components/ui/table/table.tsx @@ -13,7 +13,7 @@ const Table = forwardRef>( >(({ className, ...props }, ref) => ( - + tr:last-child>td]:after:hidden", className)} + {...props} + /> )); TableBody.displayName = "TableBody"; @@ -105,7 +109,8 @@ const TableCell = forwardRef<
- - - - } - > + } label="Edit API Key" diff --git a/ui/components/users/profile/api-keys/utils.ts b/ui/components/users/profile/api-keys/utils.ts index c0e59bf18c..aaa4638194 100644 --- a/ui/components/users/profile/api-keys/utils.ts +++ b/ui/components/users/profile/api-keys/utils.ts @@ -1,5 +1,3 @@ -import { formatDistanceToNow } from "date-fns"; - import { FALLBACK_VALUES } from "./constants"; import { API_KEY_STATUS, @@ -31,10 +29,7 @@ export const getStatusLabel = (status: ApiKeyStatus): string => { return labelMap[status] || FALLBACK_VALUES.UNKNOWN; }; -export const formatRelativeTime = (date: string | null): string => { - if (!date) return FALLBACK_VALUES.NEVER; - return formatDistanceToNow(new Date(date), { addSuffix: true }); -}; +export { formatRelativeTime } from "@/lib/date-utils"; export const calculateExpiryDate = (days: number): string => { const expiresAt = new Date(); diff --git a/ui/components/users/table/data-table-row-actions.tsx b/ui/components/users/table/data-table-row-actions.tsx index fafabb5011..09fa6978e1 100644 --- a/ui/components/users/table/data-table-row-actions.tsx +++ b/ui/components/users/table/data-table-row-actions.tsx @@ -4,8 +4,6 @@ import { Row } from "@tanstack/react-table"; import { Pencil, Trash2 } from "lucide-react"; import { useState } from "react"; -import { VerticalDotsIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; import { ActionDropdown, ActionDropdownDangerZone, @@ -59,13 +57,7 @@ export function DataTableRowActions({
- - - - } - > + } label="Edit User" diff --git a/ui/hooks/index.ts b/ui/hooks/index.ts index 75ff6da61d..8231eef4d5 100644 --- a/ui/hooks/index.ts +++ b/ui/hooks/index.ts @@ -2,6 +2,7 @@ export * from "./use-auth"; export * from "./use-credentials-form"; export * from "./use-form-server-errors"; export * from "./use-local-storage"; +export * from "./use-mount-effect"; export * from "./use-related-filters"; export * from "./use-scroll-hint"; export * from "./use-sidebar"; diff --git a/ui/hooks/use-infinite-resources.test.ts b/ui/hooks/use-infinite-resources.test.ts new file mode 100644 index 0000000000..54c577d40f --- /dev/null +++ b/ui/hooks/use-infinite-resources.test.ts @@ -0,0 +1,384 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useInfiniteResources } from "./use-infinite-resources"; + +// --------------------------------------------------------------------------- +// IntersectionObserver mock (jsdom doesn't provide one) +// --------------------------------------------------------------------------- + +type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void; + +/** Stores the latest observer callback so tests can trigger intersections. */ +let latestObserverCallback: IntersectionCallback | null = null; + +class MockIntersectionObserver { + callback: IntersectionCallback; + constructor(callback: IntersectionCallback) { + this.callback = callback; + latestObserverCallback = callback; + } + observe() {} + unobserve() {} + disconnect() { + if (latestObserverCallback === this.callback) { + latestObserverCallback = null; + } + } +} + +vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + +/** Simulate the sentinel becoming visible in the scroll container. */ +function triggerIntersection() { + latestObserverCallback?.([ + { isIntersecting: true } as IntersectionObserverEntry, + ]); +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const findingGroupActionsMock = vi.hoisted(() => ({ + getLatestFindingGroupResources: vi.fn(), + getFindingGroupResources: vi.fn(), + adaptFindingGroupResourcesResponse: vi.fn(), +})); + +vi.mock("@/actions/finding-groups", () => findingGroupActionsMock); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeApiResponse( + resources: { id: string }[], + { pages = 1 }: { pages?: number } = {}, +) { + return { + data: resources, + meta: { pagination: { pages } }, + }; +} + +function fakeResource(id: string) { + return { + findingId: id, + resourceUid: `uid-${id}`, + resourceName: `Resource ${id}`, + status: "FAIL", + severity: "high", + isMuted: false, + }; +} + +function defaultOptions(overrides?: Record) { + return { + checkId: "check_1", + hasDateOrScanFilter: false, + filters: {}, + onSetResources: vi.fn(), + onAppendResources: vi.fn(), + onSetLoading: vi.fn(), + ...overrides, + }; +} + +/** Flush all pending microtasks (awaits in fetchPage). */ +async function flushAsync() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("useInfiniteResources", () => { + beforeEach(() => { + for (const mockFn of Object.values(findingGroupActionsMock)) { + mockFn.mockReset(); + } + }); + + describe("when mounting", () => { + it("should fetch page 1 and deliver resources via onSetResources", async () => { + // Given + const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], { + pages: 1, + }); + const adapted = [fakeResource("r1"), fakeResource("r2")]; + + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + adapted, + ); + + const onSetResources = vi.fn(); + const onSetLoading = vi.fn(); + + // When + renderHook(() => + useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })), + ); + await flushAsync(); + + // Then + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).toHaveBeenCalledWith( + expect.objectContaining({ + checkId: "check_1", + page: 1, + pageSize: 10, + }), + ); + expect(onSetResources).toHaveBeenCalledWith(adapted, false); + }); + + it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => { + // Given + const apiResponse = makeApiResponse([], { pages: 1 }); + findingGroupActionsMock.getFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + [], + ); + + // When + renderHook(() => + useInfiniteResources(defaultOptions({ hasDateOrScanFilter: true })), + ); + await flushAsync(); + + // Then + expect( + findingGroupActionsMock.getFindingGroupResources, + ).toHaveBeenCalledTimes(1); + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).not.toHaveBeenCalled(); + }); + }); + + describe("when all resources fit in one page", () => { + it("should not fetch page 2 after page 1 completes", async () => { + // Given — API returns 4 resources, 1 page total + const apiResponse = makeApiResponse( + [{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }], + { pages: 1 }, + ); + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + [ + fakeResource("r1"), + fakeResource("r2"), + fakeResource("r3"), + fakeResource("r4"), + ], + ); + + // When + const { result } = renderHook(() => + useInfiniteResources(defaultOptions()), + ); + await flushAsync(); + + // Attach sentinel so observer is created + const sentinel = document.createElement("div"); + act(() => { + result.current.sentinelRef(sentinel); + }); + + // Simulate observer firing (sentinel visible after page 1 loaded) + act(() => { + triggerIntersection(); + }); + await flushAsync(); + + // Then — only page 1 was fetched, never page 2 + const calls = + findingGroupActionsMock.getLatestFindingGroupResources.mock.calls; + const pageNumbers = calls.map((c: { page: number }[]) => c[0].page); + expect(pageNumbers.every((p: number) => p === 1)).toBe(true); + }); + }); + + describe("when aborted fetch races with active fetch", () => { + it("should not reset isLoading when an aborted fetch resolves", async () => { + // Given — simulate the Strict Mode race condition: + // fetch1 starts, gets aborted, fetch2 starts, fetch1's finally runs + const onSetResources = vi.fn(); + const onSetLoading = vi.fn(); + + // fetch1 resolves slowly (after abort) + let resolveFetch1: (v: unknown) => void; + const fetch1Promise = new Promise((r) => { + resolveFetch1 = r; + }); + + // fetch2 resolves normally + const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 }); + const adapted = [fakeResource("r1")]; + + let callCount = 0; + findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation( + () => { + callCount++; + if (callCount === 1) return fetch1Promise; + return Promise.resolve(apiResponse); + }, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + adapted, + ); + + // When — mount, abort (simulating cleanup), remount + const { unmount } = renderHook(() => + useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })), + ); + + // Simulate Strict Mode: unmount triggers abort + unmount(); + + // Fetch1 resolves AFTER abort — its finally should NOT reset isLoading + await act(async () => { + resolveFetch1!(apiResponse); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Then — onSetResources should NOT have been called by the aborted fetch + // (the signal.aborted check returns early) + expect(onSetResources).not.toHaveBeenCalled(); + }); + }); + + describe("when sentinel triggers next page", () => { + it("should fetch page 2 via onAppendResources when hasMore is true", async () => { + // Given — page 1 has more pages + const page1Response = makeApiResponse( + Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })), + { pages: 3 }, + ); + const page1Adapted = Array.from({ length: 10 }, (_, i) => + fakeResource(`r${i}`), + ); + + const page2Response = makeApiResponse( + Array.from({ length: 10 }, (_, i) => ({ id: `r${10 + i}` })), + { pages: 3 }, + ); + const page2Adapted = Array.from({ length: 10 }, (_, i) => + fakeResource(`r${10 + i}`), + ); + + findingGroupActionsMock.getLatestFindingGroupResources + .mockResolvedValueOnce(page1Response) + .mockResolvedValueOnce(page2Response); + findingGroupActionsMock.adaptFindingGroupResourcesResponse + .mockReturnValueOnce(page1Adapted) + .mockReturnValueOnce(page2Adapted); + + const onSetResources = vi.fn(); + const onAppendResources = vi.fn(); + + // When — mount and wait for page 1 + const { result } = renderHook(() => + useInfiniteResources( + defaultOptions({ onSetResources, onAppendResources }), + ), + ); + await flushAsync(); + + expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true); + + // Attach sentinel and simulate intersection → triggers page 2 + const sentinel = document.createElement("div"); + act(() => { + result.current.sentinelRef(sentinel); + }); + act(() => { + triggerIntersection(); + }); + await flushAsync(); + + // Then + expect(onAppendResources).toHaveBeenCalledWith(page2Adapted, true); + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).toHaveBeenCalledTimes(2); + }); + }); + + describe("when refresh is called", () => { + it("should re-fetch page 1 and deliver via onSetResources", async () => { + // Given + const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 }); + const adapted = [fakeResource("r1")]; + + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + adapted, + ); + + const onSetResources = vi.fn(); + + const { result } = renderHook(() => + useInfiniteResources(defaultOptions({ onSetResources })), + ); + await flushAsync(); + + expect(onSetResources).toHaveBeenCalledTimes(1); + + // When — refresh (e.g. after muting) + act(() => { + result.current.refresh(); + }); + await flushAsync(); + + // Then — page 1 fetched again + expect(onSetResources).toHaveBeenCalledTimes(2); + const calls = + findingGroupActionsMock.getLatestFindingGroupResources.mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0][0].page).toBe(1); + expect(calls[1][0].page).toBe(1); + }); + }); + + describe("when filters include search params", () => { + it("should pass filters to the fetch function", async () => { + // Given + const apiResponse = makeApiResponse([], { pages: 1 }); + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + [], + ); + + const filters = { + "filter[name__icontains]": "my-resource", + "filter[severity__in]": "high", + }; + + // When + renderHook(() => useInfiniteResources(defaultOptions({ filters }))); + await flushAsync(); + + // Then + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).toHaveBeenCalledWith(expect.objectContaining({ filters })); + }); + }); +}); diff --git a/ui/hooks/use-infinite-resources.ts b/ui/hooks/use-infinite-resources.ts new file mode 100644 index 0000000000..b93acdd9c4 --- /dev/null +++ b/ui/hooks/use-infinite-resources.ts @@ -0,0 +1,206 @@ +"use client"; + +import { useRef } from "react"; + +import { + adaptFindingGroupResourcesResponse, + getFindingGroupResources, + getLatestFindingGroupResources, +} from "@/actions/finding-groups"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { FindingResourceRow } from "@/types"; + +const RESOURCES_PAGE_SIZE = 10; + +interface UseInfiniteResourcesOptions { + checkId: string; + hasDateOrScanFilter: boolean; + filters: Record; + onSetResources: (resources: FindingResourceRow[], hasMore: boolean) => void; + onAppendResources: ( + resources: FindingResourceRow[], + hasMore: boolean, + ) => void; + onSetLoading: (loading: boolean) => void; + /** Scroll container element for IntersectionObserver root. Defaults to viewport. */ + scrollContainerRef?: React.RefObject; +} + +interface UseInfiniteResourcesReturn { + sentinelRef: (node: HTMLDivElement | null) => void; + /** Reset pagination and re-fetch page 1 (e.g. after muting). */ + refresh: () => void; + /** Imperatively load the next page (e.g. from drawer navigation). */ + loadMore: () => void; +} + +/** + * Hook for paginated infinite-scroll loading of finding group resources. + * + * Uses refs for all mutable state to avoid dependency chains that + * cause infinite re-render loops. The parent component remounts this + * hook via key-prop when checkId or filters change. + */ +export function useInfiniteResources({ + checkId, + hasDateOrScanFilter, + filters, + onSetResources, + onAppendResources, + onSetLoading, + scrollContainerRef, +}: UseInfiniteResourcesOptions): UseInfiniteResourcesReturn { + // All mutable state in refs to break dependency chains + const pageRef = useRef(1); + const hasMoreRef = useRef(true); + // Start as `true` to block the IntersectionObserver from calling loadNextPage + // before the initial fetch runs. Ref callbacks fire during commit (sync), + // but useMountEffect fires after paint — the observer can sneak in between. + const isLoadingRef = useRef(true); + const currentCheckIdRef = useRef(checkId); + const controllerRef = useRef(null); + const observerRef = useRef(null); + + // Store latest values in refs so the fetch function always reads current values + // without being recreated on every render + const hasDateOrScanRef = useRef(hasDateOrScanFilter); + const filtersRef = useRef(filters); + const onSetResourcesRef = useRef(onSetResources); + const onAppendResourcesRef = useRef(onAppendResources); + const onSetLoadingRef = useRef(onSetLoading); + + // Keep refs in sync with latest props + hasDateOrScanRef.current = hasDateOrScanFilter; + filtersRef.current = filters; + onSetResourcesRef.current = onSetResources; + onAppendResourcesRef.current = onAppendResources; + onSetLoadingRef.current = onSetLoading; + + async function fetchPage( + page: number, + append: boolean, + forCheckId: string, + signal: AbortSignal, + ) { + if (isLoadingRef.current || signal.aborted) return; + + isLoadingRef.current = true; + onSetLoadingRef.current(true); + + const fetchFn = hasDateOrScanRef.current + ? getFindingGroupResources + : getLatestFindingGroupResources; + + try { + const response = await fetchFn({ + checkId: forCheckId, + page, + pageSize: RESOURCES_PAGE_SIZE, + filters: filtersRef.current, + }); + + // Discard stale response if aborted (e.g. Strict Mode remount) + if (signal.aborted) { + return; + } + + const resources = adaptFindingGroupResourcesResponse( + response, + forCheckId, + ); + const totalPages = response?.meta?.pagination?.pages ?? 1; + const hasMore = page < totalPages; + + hasMoreRef.current = hasMore; + + if (append) { + onAppendResourcesRef.current(resources, hasMore); + } else { + onSetResourcesRef.current(resources, hasMore); + } + } catch (error) { + if (!signal.aborted) { + console.error("Error fetching resources:", error); + onSetLoadingRef.current(false); + } + } finally { + // Only release the loading guard if this fetch wasn't aborted. + // An aborted fetch (e.g. Strict Mode cleanup) must NOT reset the flag + // while a subsequent fetch from the remount is still in flight. + if (!signal.aborted) { + isLoadingRef.current = false; + } + } + } + + // Fetch first page on mount — parent remounts via key-prop on checkId/filter changes + useMountEffect(() => { + const controller = new AbortController(); + controllerRef.current = controller; + + // Release the loading guard so fetchPage can proceed. + // This is synchronous with the fetchPage call below, so the observer + // cannot sneak in between these two lines. + isLoadingRef.current = false; + fetchPage(1, false, checkId, controller.signal); + + return () => { + controller.abort(); + observerRef.current?.disconnect(); + }; + }); + + function loadNextPage() { + const signal = controllerRef.current?.signal; + if ( + !hasMoreRef.current || + isLoadingRef.current || + !signal || + signal.aborted + ) + return; + + const nextPage = pageRef.current + 1; + pageRef.current = nextPage; + fetchPage(nextPage, true, currentCheckIdRef.current, signal); + } + + // IntersectionObserver callback + function handleIntersection(entries: IntersectionObserverEntry[]) { + const [entry] = entries; + if (entry.isIntersecting) { + loadNextPage(); + } + } + + // Set up observer when sentinel node changes + function sentinelRef(node: HTMLDivElement | null) { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + if (node) { + observerRef.current = new IntersectionObserver(handleIntersection, { + root: scrollContainerRef?.current ?? null, + rootMargin: "200px", + }); + observerRef.current.observe(node); + } + } + + /** Imperatively reset and re-fetch page 1 without changing deps. */ + function refresh() { + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + + pageRef.current = 1; + hasMoreRef.current = true; + isLoadingRef.current = false; + + fetchPage(1, false, currentCheckIdRef.current, controller.signal); + } + + return { sentinelRef, refresh, loadMore: loadNextPage }; +} diff --git a/ui/hooks/use-mount-effect.ts b/ui/hooks/use-mount-effect.ts new file mode 100644 index 0000000000..0e04f033e8 --- /dev/null +++ b/ui/hooks/use-mount-effect.ts @@ -0,0 +1,12 @@ +"use client"; + +import { EffectCallback, useEffect } from "react"; + +/** + * Runs an effect exactly once on component mount. + * Project-approved wrapper — use this instead of useEffect(..., []). + */ +export function useMountEffect(effect: EffectCallback) { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(effect, []); +} diff --git a/ui/lib/date-utils.ts b/ui/lib/date-utils.ts new file mode 100644 index 0000000000..40717b4e14 --- /dev/null +++ b/ui/lib/date-utils.ts @@ -0,0 +1,56 @@ +import { formatDistanceToNow } from "date-fns"; + +/** + * Formats a duration in seconds to a human-readable string like "2h 5m 30s". + */ +export function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (remainingSeconds > 0 || parts.length === 0) + parts.push(`${remainingSeconds}s`); + + return parts.join(" "); +} + +/** + * Formats a date string to a relative time like "3 days ago". + * Returns the fallback string if the date is null. + */ +export function formatRelativeTime( + date: string | null, + fallback = "Never", +): string { + if (!date) return fallback; + return formatDistanceToNow(new Date(date), { addSuffix: true }); +} + +/** + * Computes a human-readable "failing for" duration from first_seen_at to now. + * Returns null if the date is invalid or not provided. + */ +export function getFailingForLabel(firstSeenAt: string | null): string | null { + if (!firstSeenAt) return null; + + const start = new Date(firstSeenAt); + if (isNaN(start.getTime())) return null; + + const now = new Date(); + const diffMs = now.getTime() - start.getTime(); + if (diffMs < 0) return null; + + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays < 1) return "< 1 day"; + if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""}`; + + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? "s" : ""}`; + + const diffYears = Math.floor(diffMonths / 12); + return `${diffYears} year${diffYears > 1 ? "s" : ""}`; +} diff --git a/ui/lib/menu-list.ts b/ui/lib/menu-list.ts index efabbb2df9..4de7429fec 100644 --- a/ui/lib/menu-list.ts +++ b/ui/lib/menu-list.ts @@ -83,7 +83,7 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => { groupLabel: "", menus: [ { - href: "/findings?filter[muted]=false", + href: "/findings?filter[muted]=false&filter[status__in]=FAIL", label: "Findings", icon: Tag, }, diff --git a/ui/lib/region-flags.ts b/ui/lib/region-flags.ts new file mode 100644 index 0000000000..1b844ffcf0 --- /dev/null +++ b/ui/lib/region-flags.ts @@ -0,0 +1,89 @@ +/** + * Maps cloud region strings to flag emojis. + * + * Supports AWS (us-east-1), Azure (eastus), GCP (us-central1), + * and other providers with common region naming patterns. + */ + +const REGION_FLAG_RULES: [RegExp, string][] = [ + // United States + [ + /\bus[-_]?|useast|uswest|usgov|uscentral|northamerica|virginia|ohio|oregon|california/i, + "🇺🇸", + ], + // European Union / general Europe + [ + /\beu[-_]?|europe|euwest|eucentral|eunorth|eusouth|frankfurt|ireland|paris|stockholm|milan|spain|zurich/i, + "🇪🇺", + ], + // United Kingdom + [/\buk[-_]?|uksouth|ukwest|london/i, "🇬🇧"], + // Germany + [/germany|germanycentral/i, "🇩🇪"], + // France + [/france|francecentral/i, "🇫🇷"], + // Ireland + [/\bireland\b/i, "🇮🇪"], + // Sweden + [/sweden/i, "🇸🇪"], + // Switzerland + [/switzerland|switz/i, "🇨🇭"], + // Italy + [/italy|italynorth/i, "🇮🇹"], + // Spain + [/\bspain\b/i, "🇪🇸"], + // Norway + [/norway/i, "🇳🇴"], + // Poland + [/poland/i, "🇵🇱"], + // Canada + [/\bca[-_]?|canada|canadacentral|canadaeast/i, "🇨🇦"], + // Brazil + [/\bsa[-_]?|brazil|southamerica|saeast|brazilsouth/i, "🇧🇷"], + // Japan + [/\bap[-_]?northeast[-_]?1|japan|japaneast|japanwest|tokyo|osaka/i, "🇯🇵"], + // South Korea + [/\bap[-_]?northeast[-_]?[23]|korea|koreacentral|koreasouth|seoul/i, "🇰🇷"], + // Australia + [ + /\bap[-_]?southeast[-_]?2|australia|australiaeast|australiacentral|sydney|melbourne/i, + "🇦🇺", + ], + // Singapore + [/\bap[-_]?southeast[-_]?1|singapore/i, "🇸🇬"], + // India + [ + /\bap[-_]?south[-_]?1|india|centralindia|southindia|westindia|mumbai|hyderabad/i, + "🇮🇳", + ], + // Hong Kong + [/\bap[-_]?east[-_]?1|hongkong/i, "🇭🇰"], + // Indonesia + [/\bap[-_]?southeast[-_]?3|indonesia|jakarta/i, "🇮🇩"], + // China + [/\bcn[-_]?|china|chinaeast|chinanorth|beijing|shanghai|ningxia/i, "🇨🇳"], + // Middle East / UAE + [/\bme[-_]?|middleeast|uaecentral|uaenorth|dubai|bahrain/i, "🇦🇪"], + // Israel + [/israel|israelcentral/i, "🇮🇱"], + // South Africa + [/\baf[-_]?|africa|southafrica|capetown|johannesburg/i, "🇿🇦"], + // Asia Pacific (generic fallback) + [/\bap[-_]?|asia/i, "🌏"], + // Global / multi-region + [/global|multi/i, "🌐"], +]; + +export function getRegionFlag(region: string): string { + if (!region || region === "-") return ""; + + const normalized = region.toLowerCase().replace(/\s+/g, ""); + + for (const [pattern, flag] of REGION_FLAG_RULES) { + if (pattern.test(normalized)) { + return flag; + } + } + + return "🌐"; +} diff --git a/ui/styles/globals.css b/ui/styles/globals.css index daad4a6985..3eb7ffa6e0 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -81,6 +81,9 @@ /* Progress Bar */ --shadow-progress-glow: 0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary); + + /* Lighthouse AI */ + --gradient-lighthouse: linear-gradient(96deg, #2EE59B 3.55%, #62DFF0 98.85%); } /* ===== DARK THEME ===== */ diff --git a/ui/types/findings-table.ts b/ui/types/findings-table.ts new file mode 100644 index 0000000000..30198404f5 --- /dev/null +++ b/ui/types/findings-table.ts @@ -0,0 +1,61 @@ +import { FindingStatus, Severity } from "./components"; +import { ProviderType } from "./providers"; + +export const FINDINGS_ROW_TYPE = { + GROUP: "group", + RESOURCE: "resource", +} as const; + +export type FindingsRowType = + (typeof FINDINGS_ROW_TYPE)[keyof typeof FINDINGS_ROW_TYPE]; + +export interface FindingGroupRow { + id: string; + rowType: typeof FINDINGS_ROW_TYPE.GROUP; + checkId: string; + checkTitle: string; + severity: Severity; + status: FindingStatus; + resourcesTotal: number; + resourcesFail: number; + newCount: number; + changedCount: number; + mutedCount: number; + providers: ProviderType[]; + updatedAt: string; +} + +export interface FindingResourceRow { + id: string; + rowType: typeof FINDINGS_ROW_TYPE.RESOURCE; + findingId: string; + checkId: string; + providerType: ProviderType; + providerAlias: string; + providerUid: string; + resourceName: string; + resourceGroup: string; + resourceUid: string; + service: string; + region: string; + severity: Severity; + status: string; + isMuted: boolean; + mutedReason?: string; + firstSeenAt: string | null; + lastSeenAt: string | null; +} + +export type FindingsTableRow = FindingGroupRow | FindingResourceRow; + +export function isFindingGroupRow( + row: FindingsTableRow, +): row is FindingGroupRow { + return row.rowType === FINDINGS_ROW_TYPE.GROUP; +} + +export function isFindingResourceRow( + row: FindingsTableRow, +): row is FindingResourceRow { + return row.rowType === FINDINGS_ROW_TYPE.RESOURCE; +} diff --git a/ui/types/index.ts b/ui/types/index.ts index 7832c7b8d8..4b6f55e3d8 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -1,6 +1,7 @@ export * from "./authFormSchema"; export * from "./components"; export * from "./filters"; +export * from "./findings-table"; export * from "./formSchemas"; export * from "./organizations"; export * from "./processors";