feat(ui): add findings grouped view (#10425)

Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
Alejandro Bailo
2026-03-30 14:17:36 +02:00
committed by GitHub
co-authored by Adrián Jesús Peña Rodríguez Alan Buscaglia
parent 3b875484b0
commit 50556df713
84 changed files with 5306 additions and 808 deletions
+12
View File
@@ -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)
+1 -12
View File
@@ -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)
*
@@ -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,
}));
}
+144
View File
@@ -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<string, string | string[] | undefined>,
): Record<string, string | string[] | undefined> {
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<string, string | string[] | undefined>;
}) => {
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<string, string | string[] | undefined>;
}) => {
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;
}
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./finding-groups";
export * from "./finding-groups.adapter";
@@ -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<string, string[]> | null | undefined,
): string[] {
const frameworks = new Set<string>();
// 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<string, unknown>)) {
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,
};
});
}
@@ -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<string, string>) => {
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",
);
});
});
+385
View File
@@ -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<string, string>;
hasDateOrScanFilter?: boolean;
}
interface ResolveFindingIdsParams {
checkId: string;
resourceUids: string[];
filters?: Record<string, string>;
hasDateOrScanFilter?: boolean;
}
interface ResolveFindingIdsByVisibleGroupResourcesParams {
checkId: string;
filters?: Record<string, string>;
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<FindingIdsPageResponse> {
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<T>(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<string[]> {
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<string, string> {
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<FindingGroupResourceUidsPageResponse> {
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<string[]> => {
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<string[]> => {
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<string[]> => {
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;
}
};
+2
View File
@@ -1 +1,3 @@
export * from "./findings";
export * from "./findings-by-resource";
export * from "./findings-by-resource.adapter";
+3 -4
View File
@@ -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").
@@ -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;
@@ -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"
>
<TreeSpinner className="size-6" />
<Spinner className="size-6" />
<p className="text-muted-foreground text-sm">
Loading Attack Paths graph...
</p>
@@ -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 }) => (
<span className="text-sm">
{formatDuration(row.original.attributes.duration)}
{formatNullableDuration(row.original.attributes.duration)}
</span>
),
enableSorting: false,
+34 -117
View File
@@ -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=<uuid>) 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<string, unknown>;
relationships?: {
provider?: { data?: { id: string } };
};
};
const resourceDict: Record<string, unknown> = {};
const scanDict: Record<string, IncludedItem> = {};
const providerDict: Record<string, unknown> = {};
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({
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</FilterTransitionWrapper>
{processedFinding && <FindingDetailsSheet finding={processedFinding} />}
</ContentLayout>
);
}
@@ -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 && (
<div className="text-small mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-red-700">
<p className="mr-2 font-semibold">Error:</p>
<p>{findingsData.errors[0].detail}</p>
<p>{findingGroupsData.errors[0].detail}</p>
</div>
)}
<FindingsTableWithSelection
data={expandedResponse?.data || []}
metadata={findingsData?.meta}
<FindingsGroupTable
key={groupKey}
data={groups}
metadata={findingGroupsData?.meta}
/>
</>
);
+10 -1
View File
@@ -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<Record<string, string | string[] | undefined>>;
}) {
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}
/>
</div>
</ContentLayout>
@@ -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 (
<div className="flex items-center justify-center px-2">
<ActionDropdown
trigger={
<Button
variant="outline"
size="icon-sm"
className="size-7 rounded-full"
>
<VerticalDotsIcon
size={16}
className="text-text-neutral-secondary"
/>
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Mute Rule"
+2 -4
View File
@@ -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+)/);
@@ -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 (
<Sheet open={true} onOpenChange={handleOpenChange}>
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
<SheetHeader>
<SheetTitle className="sr-only">Finding Details</SheetTitle>
<SheetDescription className="sr-only">
View the finding details
</SheetDescription>
</SheetHeader>
<FindingDetail findingDetails={finding} />
</SheetContent>
</Sheet>
);
};
@@ -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<string[]>;
/** 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<string[]>([]);
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 (
<>
<MuteFindingsModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
findingIds={selectedFindingIds}
onComplete={onComplete}
findingIds={findingIds}
onComplete={handleComplete}
isBulkOperation={isBulkOperation}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={() => setIsModalOpen(true)}
onClick={handleClick}
disabled={isResolving}
size="lg"
className="shadow-lg"
>
<VolumeX className="size-5" />
Mute ({selectedCount})
{isResolving ? (
<Spinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
{label ?? `Mute (${selectedCount})`}
</Button>
</div>
</>
-1
View File
@@ -1,2 +1 @@
export * from "./finding-details-sheet";
export * from "./muted";
@@ -0,0 +1,11 @@
import ReactMarkdown from "react-markdown";
interface MarkdownContainerProps {
children: string;
}
export const MarkdownContainer = ({ children }: MarkdownContainerProps) => (
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
+21 -32
View File
@@ -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<SetStateAction<boolean>>;
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<MuteRuleActionState | null>(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);
})();
});
+15 -5
View File
@@ -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 (
<Tooltip content={mutedReason} className="text-xs">
<div className="border-system-severity-critical/40 w-fit rounded-full border p-1">
<MutedIcon className="text-system-severity-critical h-4 w-4" />
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<MutedIcon className="text-text-neutral-primary size-2" />
<span className="text-text-neutral-primary text-sm">Muted</span>
</div>
</TooltipTrigger>
<TooltipContent>
<span className="text-xs">{mutedReason}</span>
</TooltipContent>
</Tooltip>
);
};
@@ -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<FindingGroupRow>[] {
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 (
<div className="flex items-center gap-2">
<div className="w-2" />
<div className="w-4" />
<Checkbox
size="sm"
checked={headerChecked}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked === true)
}
onClick={(e) => e.stopPropagation()}
aria-label="Select all"
disabled={selectableRowCount === 0}
/>
</div>
);
},
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 (
<div className="flex items-center gap-2">
<NotificationIndicator delta={delta} isMuted={allMuted} />
<button
type="button"
aria-label={`Expand ${group.checkTitle}`}
className="hover:bg-bg-neutral-tertiary flex size-4 shrink-0 items-center justify-center rounded-md transition-colors"
onClick={() => onDrillDown(group.checkId, group)}
>
<ChevronRight
className={cn(
"text-text-neutral-secondary size-4 transition-transform duration-200",
isExpanded && "rotate-90",
)}
/>
</button>
<Checkbox
size="sm"
checked={
rowSelection[row.id] && isExpanded && hasResourceSelection
? "indeterminate"
: !!rowSelection[row.id]
}
onCheckedChange={(checked) => {
// 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"
/>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
// Status column — not sortable on finding-groups endpoint
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const rawStatus = row.original.status as string;
const status = rawStatus === "MUTED" ? "FAIL" : row.original.status;
return <StatusFindingBadge status={status} />;
},
enableSorting: false,
},
// Finding title column
{
accessorKey: "finding",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Finding Groups"
param="check_id"
/>
),
cell: ({ row }) => {
const group = row.original;
return (
<div>
<p
className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline"
onClick={() => onDrillDown(group.checkId, group)}
>
{group.checkTitle}
</p>
</div>
);
},
},
// Severity column
{
accessorKey: "severity",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Severity"
param="severity"
/>
),
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
},
// Impacted Providers column
{
id: "impactedProviders",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Impacted Providers" />
),
cell: ({ row }) => (
<ImpactedProvidersCell
providers={row.original.providers as ProviderType[]}
/>
),
enableSorting: false,
},
// Impacted Resources column
{
id: "impactedResources",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Impacted Resources" />
),
cell: ({ row }) => {
const group = row.original;
return (
<ImpactedResourcesCell
impacted={group.resourcesFail}
total={group.resourcesTotal}
/>
);
},
enableSorting: false,
},
// Actions column
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => <DataTableRowActions row={row} />,
enableSorting: false,
},
];
}
@@ -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<FindingResourceRow> }) => {
const resource = row.original;
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
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 && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
/>
)}
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<ActionDropdown ariaLabel="Resource actions">
<ActionDropdownItem
icon={
resource.isMuted ? (
<VolumeOff className="size-5" />
) : isResolving ? (
<Spinner className="size-5" />
) : (
<VolumeX className="size-5" />
)
}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={resource.isMuted || isResolving}
onSelect={handleMuteClick}
/>
</ActionDropdown>
</div>
</>
);
};
interface GetColumnFindingResourcesOptions {
rowSelection: RowSelectionState;
selectableRowCount: number;
}
export function getColumnFindingResources({
rowSelection,
selectableRowCount,
}: GetColumnFindingResourcesOptions): ColumnDef<FindingResourceRow>[] {
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 (
<div className="flex items-center gap-2">
<div className="w-2" />
<div className="w-4" />
<Checkbox
size="sm"
checked={headerChecked}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked === true)
}
onClick={(e) => e.stopPropagation()}
aria-label="Select all resources"
disabled={selectableRowCount === 0}
/>
</div>
);
},
cell: ({ row }) => (
<div className="flex items-center gap-2">
<NotificationIndicator
isMuted={row.original.isMuted}
mutedReason={row.original.mutedReason}
/>
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
<Checkbox
size="sm"
checked={!!rowSelection[row.id]}
disabled={row.original.isMuted}
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
onClick={(e) => e.stopPropagation()}
aria-label="Select resource"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
// Resource — name + uid (EntityInfo with resource icon)
{
id: "resource",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource" />
),
cell: ({ row }) => (
<div className="max-w-[240px]">
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={row.original.resourceGroup}
entityId={row.original.resourceUid}
/>
</div>
),
enableSorting: false,
},
// Status
{
id: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const rawStatus = row.original.status;
const status =
rawStatus === "MUTED" ? "FAIL" : (rawStatus as FindingStatus);
return <StatusFindingBadge status={status} />;
},
enableSorting: false,
},
// Service
{
id: "service",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Service" />
),
cell: ({ row }) => (
<p className="text-text-neutral-primary max-w-[100px] truncate text-sm">
{row.original.service}
</p>
),
enableSorting: false,
},
// Region
{
id: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" />
),
cell: ({ row }) => (
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{row.original.region}
</p>
),
enableSorting: false,
},
// Severity
{
id: "severity",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Severity" />
),
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
enableSorting: false,
},
// Account — alias + uid (EntityInfo with provider logo)
{
id: "account",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Account" />
),
cell: ({ row }) => (
<div className="max-w-[240px]">
<EntityInfo
cloudProvider={row.original.providerType}
entityAlias={row.original.providerAlias}
entityId={row.original.providerUid}
/>
</div>
),
enableSorting: false,
},
// Last seen
{
id: "lastSeen",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Last seen" />
),
cell: ({ row }) => (
<InfoField label="Last seen" variant="compact">
<DateWithTime dateTime={row.original.lastSeenAt} inline />
</InfoField>
),
enableSorting: false,
},
// Failing for — duration since first_seen_at
{
id: "failingFor",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Failing for" />
),
cell: ({ row }) => {
const duration = getFailingForLabel(row.original.firstSeenAt);
return (
<InfoField label="Failing for" variant="compact">
{duration || "-"}
</InfoField>
);
},
enableSorting: false,
},
// Actions column — mute only
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => <ResourceRowActions row={row} />,
enableSorting: false,
},
];
}
@@ -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";
@@ -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<T extends FindingRowData> {
@@ -40,48 +65,68 @@ export function DataTableRowActions<T extends FindingRowData>({
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<string[]>([]);
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<T extends FindingRowData>({
return (
<>
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={findingTitle}
/>
{!isGroup && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={findingTitle}
/>
)}
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={getMuteIds()}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
isBulkOperation={finding.rowType === "group"}
/>
<div className="flex items-center justify-end">
<ActionDropdown
trigger={
<button
type="button"
aria-label="Finding actions"
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
>
<VerticalDotsIcon
size={20}
className="text-text-neutral-secondary"
/>
</button>
}
ariaLabel="Finding actions"
>
<ActionDropdown ariaLabel="Finding actions">
<ActionDropdownItem
icon={
isMuted ? (
<VolumeOff className="size-5" />
) : isResolving ? (
<Spinner className="size-5" />
) : (
<VolumeX className="size-5" />
)
}
label={getMuteLabel()}
disabled={isMuted}
onSelect={() => {
setIsMuteModalOpen(true);
}}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={isMuted || isResolving}
onSelect={handleMuteClick}
/>
{!isGroup && (
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
)}
</ActionDropdown>
</div>
</>
@@ -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 (
<Tooltip
className="pointer-events-auto"
content={
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"h-2 w-2 min-w-2 cursor-pointer rounded-full",
delta === "new"
? "bg-system-severity-high"
: delta === "changed"
? "bg-system-severity-low"
: "bg-text-neutral-tertiary",
)}
/>
</TooltipTrigger>
<TooltipContent>
<div className="flex gap-1 text-xs">
<span>
{delta === "new"
@@ -35,18 +49,7 @@ export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
</a>
</Button>
</div>
}
>
<div
className={cn(
"h-2 w-2 min-w-2 cursor-pointer rounded-full",
delta === "new"
? "bg-system-severity-high"
: delta === "changed"
? "bg-system-severity-low"
: "bg-gray-500",
)}
/>
</TooltipContent>
</Tooltip>
);
};
@@ -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 (
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
};
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 = ({
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Tabs key={findingDetails.id} defaultValue="general" className="w-full">
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
@@ -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<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
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<string, string> = {};
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<FindingResourceRow>): 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 (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds,
selectedFindings: [],
clearSelection,
isSelected,
resolveMuteIds: resolveResourceIds,
onMuteComplete: handleMuteComplete,
}}
>
<div
className={cn(
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary",
"flex w-full flex-col overflow-auto border",
)}
>
{/* Sticky header — expanded finding group summary */}
<div className="bg-bg-neutral-secondary border-border-neutral-secondary sticky top-0 z-10 border-b p-4">
<div className="flex items-center gap-3">
{/* Back button */}
<button
type="button"
aria-label="Collapse and go back to findings"
className="hover:bg-bg-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors"
onClick={onCollapse}
>
<ChevronLeft className="text-text-neutral-secondary size-5" />
</button>
{/* Notification indicator */}
<NotificationIndicator delta={delta} isMuted={allMuted} />
{/* Status badge */}
<StatusFindingBadge status={group.status} />
{/* Finding title */}
<div className="min-w-0 flex-1">
<p className="text-text-neutral-primary truncate text-sm font-medium">
{group.checkTitle}
</p>
</div>
{/* Severity */}
<SeverityBadge severity={group.severity} />
{/* Impacted resources count */}
<ImpactedResourcesCell
impacted={group.resourcesFail}
total={group.resourcesTotal}
/>
</div>
</div>
{/* Resources table */}
<div className="p-4 pt-0">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows?.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => drawer.openDrawer(row.index)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : !isLoading ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No resources found.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center justify-center gap-2 py-8">
<Spinner className="size-6" />
<span className="text-text-neutral-tertiary text-sm">
Loading resources...
</span>
</div>
)}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
</div>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onBeforeOpen={async () => {
return resolveResourceIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
isBulkOperation
/>
)}
<ResourceDetailDrawer
open={drawer.isOpen}
onOpenChange={(open) => {
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}
/>
</FindingsSelectionContext.Provider>
);
}
@@ -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<RowSelectionState>({});
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
null,
);
const [resourceSearch, setResourceSearch] = useState("");
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
const inlineRef = useRef<InlineResourceContainerHandle>(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<string, string> = {};
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<FindingGroupRow>): 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<FindingGroupRow>) => {
const group = row.original;
if (group.checkId !== expandedCheckId || !expandedGroup) return null;
return (
<InlineResourceContainer
ref={inlineRef}
key={`${group.checkId}|${searchParams.toString()}|${resourceSearch}`}
group={expandedGroup}
resourceSearch={resourceSearch}
columnCount={columns.length}
onResourceSelectionChange={setResourceSelection}
/>
);
};
return (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: selectedCheckIds,
selectedFindings,
clearSelection,
isSelected,
resolveMuteIds,
}}
>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
controlledSearch={expandedCheckId ? resourceSearch : undefined}
onSearchChange={expandedCheckId ? setResourceSearch : undefined}
searchBadge={
expandedGroup
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
: undefined
}
renderAfterRow={renderAfterRow}
/>
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
<FloatingMuteButton
selectedCount={selectedCheckIds.length + resourceSelection.length}
selectedFindingIds={[...selectedCheckIds, ...resourceSelection]}
label={buildMuteLabel(
selectedCheckIds.length,
resourceSelection.length,
)}
onBeforeOpen={async () => {
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
}
/>
)}
</FindingsSelectionContext.Provider>
);
}
@@ -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<string[]>;
/** Called after a mute operation completes to refresh data. */
onMuteComplete?: () => void;
}
export const FindingsSelectionContext =
@@ -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<RowSelectionState>({});
// 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<FindingProps>): 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 (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds,
selectedFindings,
clearSelection,
isSelected,
}}
>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
/>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onComplete={handleMuteComplete}
/>
)}
</FindingsSelectionContext.Provider>
);
}
@@ -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 <span className="text-text-neutral-tertiary text-sm">-</span>;
}
const visible = providers.slice(0, MAX_VISIBLE_PROVIDERS);
const remaining = providers.length - MAX_VISIBLE_PROVIDERS;
return (
<div className="flex items-center gap-1">
{visible.map((provider) => (
<ProviderIconCell
key={provider}
provider={provider}
size={28}
className="size-7"
/>
))}
{remaining > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-text-neutral-tertiary cursor-default text-xs font-medium">
+{remaining}
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-xs">
{providers.slice(MAX_VISIBLE_PROVIDERS).join(", ")}
</span>
</TooltipContent>
</Tooltip>
)}
</div>
);
};
@@ -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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-sm",
"border-border-tag-primary bg-bg-tag-primary text-text-neutral-primary",
)}
>
<Badge variant="tag" className="rounded text-sm">
{isInProgress ? (
<Flag className="size-3 fill-sky-400 text-sky-400" />
) : (
@@ -39,7 +34,7 @@ const TriageBadge = ({ status, count }: TriageBadgeProps) => {
<span className="font-normal">
{isInProgress ? "In-progress" : "Resolved"}
</span>
</span>
</Badge>
);
};
@@ -58,16 +53,11 @@ export const ImpactedResourcesCell = ({
}: ImpactedResourcesCellProps) => {
return (
<div className="flex items-center gap-6">
<span
className={cn(
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-sm",
"border-border-tag-primary bg-bg-tag-primary text-text-neutral-primary",
)}
>
<Badge variant="tag" className="rounded text-sm">
<span className="font-bold">{impacted}</span>
<span className="font-normal">of</span>
<span className="font-bold">{total}</span>
</span>
</Badge>
{inProgress > 0 && (
<TriageBadge
+10 -3
View File
@@ -1,11 +1,18 @@
export * from "./column-finding-groups";
export * from "./column-finding-resources";
export * from "./column-findings";
export * from "./data-table-row-actions";
export * from "./data-table-row-details";
export * from "./finding-detail";
export * from "./findings-group-drill-down";
export * from "./findings-group-table";
export * from "./findings-selection-context";
export * from "./findings-table-with-selection";
// TODO: PROWLER-379 - Re-export when backend supports grouped findings
// export * from "./impacted-resources-cell";
// TODO: Remove legacy exports once /resources and overview dashboard migrate to grouped view components
// export * from "./column-findings";
// export * from "./data-table-row-details";
// export * from "./finding-detail";
export * from "./impacted-providers-cell";
export * from "./impacted-resources-cell";
export * from "./notification-indicator";
export * from "./provider-icon-cell";
export * from "./skeleton-table-findings";
@@ -0,0 +1,414 @@
"use client";
import {
flexRender,
getCoreRowModel,
Row,
RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useImperativeHandle, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TableCell, TableRow } from "@/components/ui/table";
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { hasDateOrScanFilter } from "@/lib";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
ResourceDetailDrawer,
useResourceDetailDrawer,
} from "./resource-detail-drawer";
export interface InlineResourceContainerHandle {
/** Soft-refresh resources (re-fetch page 1 without skeletons). */
refresh: () => 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<InlineResourceContainerHandle>;
}
// 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 (
<TableRow className="hover:bg-transparent">
{/* Select: indicator + corner arrow + checkbox */}
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="size-1.5 rounded-full" />
<Skeleton className="size-4 rounded" />
<div className="bg-bg-input-primary border-border-input-primary size-5 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
</div>
</TableCell>
{/* Resource: icon + name + uid */}
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<div className="space-y-1">
<Skeleton className="h-3.5 w-32 rounded" />
<Skeleton className="h-3 w-20 rounded" />
</div>
</div>
</TableCell>
{/* Status */}
<TableCell>
<Skeleton className="h-6 w-11 rounded-md" />
</TableCell>
{/* Service */}
<TableCell>
<Skeleton className="h-4 w-16 rounded" />
</TableCell>
{/* Region */}
<TableCell>
<Skeleton className="h-4 w-20 rounded" />
</TableCell>
{/* Severity */}
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full" />
<Skeleton className="h-4 w-12 rounded" />
</div>
</TableCell>
{/* Account: provider icon + alias + uid */}
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<div className="space-y-1">
<Skeleton className="h-3.5 w-24 rounded" />
<Skeleton className="h-3 w-16 rounded" />
</div>
</div>
</TableCell>
{/* Last seen */}
<TableCell>
<Skeleton className="h-4 w-24 rounded" />
</TableCell>
{/* Failing for */}
<TableCell>
<Skeleton className="h-4 w-16 rounded" />
</TableCell>
{/* Actions */}
<TableCell>
<Skeleton className="size-6 rounded" />
</TableCell>
</TableRow>
);
}
export function InlineResourceContainer({
group,
resourceSearch,
columnCount,
onResourceSelectionChange,
ref,
}: InlineResourceContainerProps) {
const searchParams = useSearchParams();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
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<string, string> = {};
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<FindingResourceRow>): 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 (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds,
selectedFindings: [],
clearSelection,
isSelected,
resolveMuteIds: resolveResourceIds,
onMuteComplete: handleMuteComplete,
}}
>
<tr>
<td colSpan={columnCount} className="p-0">
<AnimatePresence initial>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
<table className="mt-[-10] w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({
length: Math.min(
group.resourcesTotal,
MAX_SKELETON_ROWS,
),
}).map((_, i) => <ResourceSkeletonRow key={i} />)
) : rows.length > 0 ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => drawer.openDrawer(row.index)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No resources found.
</TableCell>
</TableRow>
)}
</tbody>
</table>
{/* Spinner for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<div className="flex items-center justify-center gap-2 py-8">
<Spinner className="size-6" />
<span className="text-text-neutral-tertiary text-sm">
Loading resources...
</span>
</div>
)}
{/* Sentinel for scroll hint detection */}
<div
ref={scrollHintSentinelRef}
aria-hidden
className="h-px shrink-0"
/>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* Gradients rendered after scroll container so they paint on top */}
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
{/* Scroll hint */}
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
<ChevronsDown className="inline size-3.5" /> Scroll for
more
</div>
</div>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</td>
</tr>
{createPortal(
<ResourceDetailDrawer
open={drawer.isOpen}
onOpenChange={(open) => {
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,
)}
</FindingsSelectionContext.Provider>
);
}
@@ -31,22 +31,34 @@ export const NotificationIndicator = ({
}: NotificationIndicatorProps) => {
// Muted takes precedence over delta
if (isMuted) {
const ruleName = mutedReason || "Unknown rule";
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="ml-1 flex cursor-pointer items-center justify-center">
<div
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<MutedIcon className="text-bg-data-muted size-2" />
</div>
</TooltipTrigger>
<TooltipContent>
<TooltipContent onClick={(e) => e.stopPropagation()}>
<Link
href="/mutelist"
onClick={(e) => e.stopPropagation()}
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
>
<span className="text-text-neutral-primary">Mute rule:</span>
<span className="max-w-[150px] truncate">{ruleName}</span>
{/* TODO: always show rule name once the API returns muted_reason in finding-group-resources */}
{mutedReason ? (
<>
<span className="text-text-neutral-primary">Mute rule:</span>
<span className="max-w-[150px] truncate">{mutedReason}</span>
</>
) : (
<>
<span className="text-text-neutral-primary">Mute rule:</span>
<span>view rules</span>
</>
)}
</Link>
</TooltipContent>
</Tooltip>
@@ -59,13 +71,18 @@ export const NotificationIndicator = ({
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"ml-1 size-1.5 cursor-pointer rounded-full",
delta === DeltaValues.NEW
? "bg-system-severity-high"
: "bg-system-severity-low",
)}
/>
onClick={(e) => e.stopPropagation()}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
>
<div
className={cn(
"size-1.5 rounded-full",
delta === DeltaValues.NEW
? "bg-system-severity-high"
: "bg-system-severity-low",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-1 text-xs">
@@ -96,5 +113,5 @@ export const NotificationIndicator = ({
}
// No indicator - return minimal width placeholder
return <div className="w-2" />;
return <div className="w-2 shrink-0" />;
};
@@ -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 (
<div className="flex size-8 items-center justify-center rounded-md bg-white">
<div className={cn("flex items-center justify-center", className)}>
<span className="text-text-neutral-secondary text-xs">?</span>
</div>
);
}
return (
<div className="flex size-8 items-center justify-center overflow-hidden rounded-md bg-white">
<div
className={cn(
"flex items-center justify-center overflow-hidden",
className,
)}
>
<IconComponent width={size} height={size} />
</div>
);
@@ -0,0 +1,2 @@
export { ResourceDetailDrawer } from "./resource-detail-drawer";
export { useResourceDetailDrawer } from "./use-resource-detail-drawer";
@@ -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 (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
{/* Header skeleton */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<Skeleton className="h-6 w-14 rounded-md" />
<Skeleton className="h-6 w-16 rounded-md" />
</div>
<Skeleton className="h-6 w-3/4 rounded" />
</div>
{/* Navigation skeleton */}
<div className="flex items-center justify-between">
<Skeleton className="h-7 w-48 rounded" />
<div className="flex gap-1">
<Skeleton className="size-8 rounded-md" />
<Skeleton className="size-8 rounded-md" />
</div>
</div>
{/* Resource card skeleton */}
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 rounded-lg border p-4">
<ResourceDetailSkeleton />
</div>
</div>
);
}
if (!checkMeta) {
return (
<div className="flex flex-1 items-center justify-center py-16">
<p className="text-text-neutral-tertiary text-sm">
No finding data available for this resource.
</p>
</div>
);
}
// 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 (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
{f && !f.isMuted && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[f.id]}
onComplete={() => {
setIsMuteModalOpen(false);
onMuteComplete();
}}
/>
)}
{f && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={f.id}
findingTitle={checkMeta.checkTitle}
/>
)}
{/* Header: status badges + title (check-level from checkMeta) */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
{f && <StatusFindingBadge status={f.status as FindingStatus} />}
{f && <SeverityBadge severity={f.severity} />}
{f?.delta && (
<div className="flex items-center gap-1 capitalize">
<DeltaIndicator delta={f.delta} />
<span className="text-text-neutral-secondary text-xs">
{f.delta}
</span>
</div>
)}
{f && (
<Muted
isMuted={f.isMuted}
mutedReason={f.mutedReason || "This finding is muted"}
/>
)}
</div>
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{checkMeta.checkTitle}
</h2>
{checkMeta.complianceFrameworks.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-text-neutral-tertiary text-xs font-medium">
Compliance Frameworks:
</span>
<div className="flex flex-wrap items-center gap-2">
{checkMeta.complianceFrameworks.map((framework) => {
const icon = getComplianceIcon(framework);
return icon ? (
<Tooltip key={framework}>
<TooltipTrigger asChild>
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
<Image
src={icon}
alt={framework}
width={20}
height={20}
className="size-5 object-contain"
/>
</div>
</TooltipTrigger>
<TooltipContent>{framework}</TooltipContent>
</Tooltip>
) : (
<Tooltip key={framework}>
<TooltipTrigger asChild>
<span className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs">
{framework}
</span>
</TooltipTrigger>
<TooltipContent>{framework}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
)}
</div>
{/* Navigation: "Impacted Resource (X of N)" */}
<div className="flex items-center justify-between">
<Badge variant="tag" className="rounded text-sm">
Impacted Resource
<span className="font-bold">{currentIndex + 1}</span>
<span className="font-normal">of</span>
<span className="font-bold">{totalResources}</span>
</Badge>
<div className="flex items-center gap-0">
<button
type="button"
disabled={!hasPrev}
onClick={onNavigatePrev}
className="text-text-neutral-secondary hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
aria-label="Previous resource"
>
<CircleChevronLeft className="size-5" />
</button>
<button
type="button"
disabled={!hasNext}
onClick={onNavigateNext}
className="text-text-neutral-secondary hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
aria-label="Next resource"
>
<CircleChevronRight className="size-5" />
</button>
</div>
</div>
{/* Resource card */}
<div className="border-border-neutral-secondary bg-bg-neutral-secondary minimal-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto rounded-lg border p-4">
{/* Resource info — shows loading when currentFinding is not yet available */}
{!f || isNavigating ? (
<ResourceDetailSkeleton />
) : (
<>
<div className="flex items-start gap-4">
{/* Resource info grid — 4 data columns */}
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
{/* Row 1: Account, Resource, Service, Region */}
<EntityInfo
cloudProvider={f.providerType}
nameIcon={<Box className="size-4" />}
entityAlias={f.providerAlias}
entityId={f.providerUid}
/>
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={f.resourceGroup}
entityId={f.resourceUid}
idLabel="UID"
/>
<InfoField label="Service" variant="compact">
{f.resourceService}
</InfoField>
<InfoField label="Region" variant="compact">
<span className="flex items-center gap-1.5">
{getRegionFlag(f.resourceRegion) && (
<span className="translate-y-px text-base leading-none">
{getRegionFlag(f.resourceRegion)}
</span>
)}
{f.resourceRegion}
</span>
</InfoField>
{/* Row 2: Dates */}
<InfoField label="Last detected" variant="compact">
<DateWithTime inline dateTime={f.updatedAt || "-"} />
</InfoField>
<InfoField label="First seen" variant="compact">
<DateWithTime inline dateTime={f.firstSeenAt || "-"} />
</InfoField>
<InfoField label="Failing for" variant="compact">
{getFailingForLabel(f.firstSeenAt) || "-"}
</InfoField>
<div className="hidden md:block" />
{/* Row 3: IDs */}
<InfoField label="Check ID" variant="compact">
<CodeSnippet
value={checkMeta.checkId}
transparent
className="max-w-full text-sm"
/>
</InfoField>
<InfoField label="Finding ID" variant="compact">
<CodeSnippet
value={f.id}
transparent
className="max-w-full text-sm"
/>
</InfoField>
<InfoField label="Finding UID" variant="compact">
<CodeSnippet
value={f.uid}
transparent
className="max-w-full text-sm"
/>
</InfoField>
</div>
{/* Actions button — fixed size, aligned with row 1 */}
<div className="shrink-0">
<ActionDropdown variant="bordered" ariaLabel="Resource actions">
<ActionDropdownItem
icon={
f.isMuted ? (
<VolumeOff className="size-5" />
) : (
<VolumeX className="size-5" />
)
}
label={f.isMuted ? "Muted" : "Mute"}
disabled={f.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
</ActionDropdown>
</div>
</div>
</>
)}
{/* Tabs */}
<Tabs
defaultValue="overview"
className="mt-2 flex min-h-fit w-full flex-1 flex-col md:min-h-0"
>
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
<TabsTrigger value="other-findings">
Other Findings For This Resource
</TabsTrigger>
<TabsTrigger value="scans">Scans</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
</div>
{/* Finding Overview — check-level data from checkMeta (always stable) */}
<TabsContent
value="overview"
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
>
{/* Card 1: Risk + Description + Status Extended */}
{(checkMeta.risk || checkMeta.description || f?.statusExtended) && (
<Card variant="inner">
{checkMeta.risk && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Risk:
</span>
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
</div>
)}
{checkMeta.description && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Description:
</span>
<MarkdownContainer>
{checkMeta.description}
</MarkdownContainer>
</div>
)}
{f?.statusExtended && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Status Extended:
</span>
<p className="text-text-neutral-primary text-sm">
{f.statusExtended}
</p>
</div>
)}
</Card>
)}
{/* Card 2: Remediation + Commands */}
{(checkMeta.remediation.recommendation.text ||
checkMeta.remediation.code.cli ||
checkMeta.remediation.code.terraform ||
checkMeta.remediation.code.nativeiac) && (
<Card variant="inner">
{checkMeta.remediation.recommendation.text && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Remediation:
</span>
<div className="flex items-start gap-3">
<div className="text-text-neutral-primary flex-1 text-sm">
<MarkdownContainer>
{checkMeta.remediation.recommendation.text}
</MarkdownContainer>
</div>
{checkMeta.remediation.recommendation.url && (
<CustomLink
href={checkMeta.remediation.recommendation.url}
size="sm"
className="shrink-0"
>
View in Prowler Hub
</CustomLink>
)}
</div>
</div>
)}
{checkMeta.remediation.code.cli && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
CLI Command:
</span>
<CodeSnippet
value={`$ ${checkMeta.remediation.code.cli}`}
multiline
transparent
className="max-w-full text-sm"
/>
</div>
)}
{checkMeta.remediation.code.terraform && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Terraform Command:
</span>
<CodeSnippet
value={`$ ${checkMeta.remediation.code.terraform}`}
multiline
transparent
className="max-w-full text-sm"
/>
</div>
)}
{checkMeta.remediation.code.nativeiac && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
CloudFormation Command:
</span>
<CodeSnippet
value={`$ ${checkMeta.remediation.code.nativeiac}`}
multiline
transparent
className="max-w-full text-sm"
/>
</div>
)}
{checkMeta.remediation.code.other && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Remediation Steps:
</span>
<MarkdownContainer>
{checkMeta.remediation.code.other}
</MarkdownContainer>
</div>
)}
</Card>
)}
{checkMeta.additionalUrls.length > 0 && (
<Card variant="inner">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
References:
</span>
<ul className="list-inside list-disc space-y-1">
{checkMeta.additionalUrls.map((link, idx) => (
<li key={idx}>
<CustomLink
href={link}
size="sm"
className="break-all whitespace-normal!"
>
{link}
</CustomLink>
</li>
))}
</ul>
</div>
</Card>
)}
{checkMeta.categories.length > 0 && (
<Card variant="inner">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Categories:
</span>
<p className="text-text-neutral-primary text-sm">
{checkMeta.categories.join(", ")}
</p>
</div>
</Card>
)}
</TabsContent>
{/* Other Findings For This Resource */}
<TabsContent
value="other-findings"
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
>
{!f || isNavigating ? (
<div className="flex items-center justify-center py-8">
<Spinner className="size-5" />
</div>
) : (
<>
<div className="flex items-center justify-between">
<h4 className="text-text-neutral-primary text-sm font-medium">
Failed Findings For This Resource
</h4>
<span className="text-text-neutral-tertiary text-sm">
{otherFindings.length} Total Entries
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Status
</span>
</TableHead>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Finding
</span>
</TableHead>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Severity
</span>
</TableHead>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Time
</span>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{otherFindings.length > 0 ? (
otherFindings.map((finding) => (
<OtherFindingRow key={finding.id} finding={finding} />
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-16 text-center">
<span className="text-text-neutral-tertiary text-sm">
No other findings for this resource.
</span>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
</TabsContent>
{/* Scans Tab */}
<TabsContent value="scans" className="flex flex-col gap-4">
{f?.scan ? (
<>
<div className="flex items-center justify-between">
<p className="text-text-neutral-secondary text-xs">
Showing the latest scan that evaluated this finding
</p>
<Button variant="link" size="link-sm" asChild>
<Link
href={`/scans?id=${f.scan.id}`}
target="_blank"
rel="noopener noreferrer"
>
View scan
<ExternalLink className="size-3" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<InfoField label="Scan Name" variant="compact">
{f.scan.name || "N/A"}
</InfoField>
<InfoField label="Resources Scanned" variant="compact">
{f.scan.uniqueResourceCount}
</InfoField>
<InfoField label="Progress" variant="compact">
{f.scan.progress}%
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<InfoField label="Trigger" variant="compact">
{f.scan.trigger}
</InfoField>
<InfoField label="State" variant="compact">
{f.scan.state}
</InfoField>
<InfoField label="Duration" variant="compact">
{formatDuration(f.scan.duration)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InfoField label="Started At" variant="compact">
<DateWithTime inline dateTime={f.scan.startedAt || "-"} />
</InfoField>
<InfoField label="Completed At" variant="compact">
<DateWithTime inline dateTime={f.scan.completedAt || "-"} />
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InfoField label="Launched At" variant="compact">
<DateWithTime inline dateTime={f.scan.insertedAt || "-"} />
</InfoField>
{f.scan.scheduledAt && (
<InfoField label="Scheduled At" variant="compact">
<DateWithTime inline dateTime={f.scan.scheduledAt} />
</InfoField>
)}
</div>
</>
) : (
<p className="text-text-neutral-tertiary text-sm">
Scan information is not available.
</p>
)}
</TabsContent>
{/* Events Tab */}
<TabsContent
value="events"
className="flex min-h-0 flex-1 flex-col gap-4"
>
<EventsTimeline
resourceId={f?.resourceId}
isAwsProvider={f?.providerType === "aws"}
/>
</TabsContent>
</Tabs>
</div>
{/* Lighthouse AI button */}
<a
href={`/lighthouse?${new URLSearchParams({ prompt: `Analyze this security finding and provide remediation guidance:\n\n- **Finding**: ${checkMeta.checkTitle}\n- **Check ID**: ${checkMeta.checkId}\n- **Severity**: ${f?.severity ?? "unknown"}\n- **Status**: ${f?.status ?? "unknown"}${f?.statusExtended ? `\n- **Detail**: ${f.statusExtended}` : ""}${checkMeta.risk ? `\n- **Risk**: ${checkMeta.risk}` : ""}` }).toString()}`}
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-950 transition-opacity hover:opacity-90"
style={{
background: "var(--gradient-lighthouse)",
}}
>
<CircleArrowRight className="size-5" />
View This Finding With Lighthouse AI
</a>
</div>
);
}
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 && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[finding.id]}
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={finding.checkTitle}
/>
<TableRow
className="cursor-pointer"
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
>
<TableCell className="w-10">
<NotificationIndicator isMuted={finding.isMuted} />
</TableCell>
<TableCell>
<StatusFindingBadge status={finding.status as FindingStatus} />
</TableCell>
<TableCell>
<p className="text-text-neutral-primary max-w-[300px] truncate text-sm">
{finding.checkTitle}
</p>
</TableCell>
<TableCell>
<SeverityBadge severity={finding.severity} />
</TableCell>
<TableCell>
<DateWithTime dateTime={finding.updatedAt} />
</TableCell>
<TableCell className="w-10">
<div onClick={(e) => e.stopPropagation()}>
<ActionDropdown ariaLabel="Finding actions">
<ActionDropdownItem
icon={
finding.isMuted ? (
<VolumeOff className="size-5" />
) : (
<VolumeX className="size-5" />
)
}
label={finding.isMuted ? "Muted" : "Mute"}
disabled={finding.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
</ActionDropdown>
</div>
</TableCell>
</TableRow>
</>
);
}
@@ -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 (
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
<DrawerContent className="3xl:w-1/3 h-full w-full overflow-hidden p-6 outline-none md:w-1/2 md:max-w-none md:min-w-[720px]">
<DrawerHeader className="sr-only">
<DrawerTitle>Resource Finding Details</DrawerTitle>
<DrawerDescription>
View finding details for the selected resource
</DrawerDescription>
</DrawerHeader>
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DrawerClose>
{open && (
<ResourceDetailDrawerContent
isLoading={isLoading}
isNavigating={isNavigating}
checkMeta={checkMeta}
currentIndex={currentIndex}
totalResources={totalResources}
currentFinding={currentFinding}
otherFindings={otherFindings}
onNavigatePrev={onNavigatePrev}
onNavigateNext={onNavigateNext}
onMuteComplete={onMuteComplete}
/>
)}
</DrawerContent>
</Drawer>
);
}
@@ -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 (
<div className="flex items-start gap-4">
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
{/* Row 1: Account, Resource, Service, Region */}
<EntityInfoSkeleton hasIcon />
<EntityInfoSkeleton />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
{/* Row 2: Last detected, First seen, Failing for */}
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
<div className="hidden md:block" />
{/* Row 3: Check ID, Finding ID, Finding UID */}
<InfoFieldSkeleton labelWidth="w-14" valueWidth="w-36" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-36" />
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-36" />
</div>
{/* Actions button */}
<Skeleton className="size-11 shrink-0 rounded-full" />
</div>
);
}
function EntityInfoSkeleton({ hasIcon = false }: { hasIcon?: boolean }) {
return (
<div className="flex items-center gap-4">
{hasIcon && <Skeleton className="size-9 shrink-0 rounded-md" />}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-5 w-28 rounded" />
</div>
<Skeleton className="h-6 w-24 rounded-full" />
</div>
</div>
);
}
function InfoFieldSkeleton({
labelWidth,
valueWidth,
}: {
labelWidth: string;
valueWidth: string;
}) {
return (
<div className="flex flex-col gap-1">
<Skeleton className={`h-3.5 ${labelWidth} rounded`} />
<Skeleton className={`h-5 ${valueWidth} rounded`} />
</div>
);
}
@@ -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<ResourceDrawerFinding[]>([]);
const [isNavigating, setIsNavigating] = useState(false);
const cacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(new Map());
const checkMetaRef = useRef<CheckMeta | null>(null);
const fetchControllerRef = useRef<AbortController | null>(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,
};
}
@@ -9,6 +9,10 @@ const SkeletonTableRow = () => {
<Skeleton className="size-1.5 rounded-full" />
</div>
</td>
{/* Expand chevron */}
<td className="px-1 py-4">
<Skeleton className="size-5 rounded" />
</td>
{/* Checkbox */}
<td className="px-2 py-4">
<div className="bg-bg-input-primary border-border-input-primary size-6 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
@@ -24,14 +28,6 @@ const SkeletonTableRow = () => {
<Skeleton className="h-4 w-4/5 rounded" />
</div>
</td>
{/* Resource name chip */}
<td className="px-3 py-4">
<div className="bg-bg-neutral-tertiary flex h-8 w-28 items-center gap-2 rounded-lg px-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-3.5 w-16 rounded" />
<Skeleton className="ml-auto size-3.5 rounded" />
</div>
</td>
{/* Severity */}
<td className="px-3 py-4">
<div className="flex items-center gap-2">
@@ -39,21 +35,17 @@ const SkeletonTableRow = () => {
<Skeleton className="h-4 w-12 rounded" />
</div>
</td>
{/* Provider icon */}
{/* Provider icons */}
<td className="px-3 py-4">
<Skeleton className="size-9 rounded-lg" />
</td>
{/* Service */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-20 rounded" />
</td>
{/* Time */}
<td className="px-3 py-4">
<div className="space-y-1">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-3 w-20 rounded" />
<div className="flex items-center gap-1">
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
</td>
{/* Resources badge */}
<td className="px-3 py-4">
<Skeleton className="h-6 w-16 rounded-md" />
</td>
{/* Actions */}
<td className="px-2 py-4">
<Skeleton className="size-6 rounded" />
@@ -81,6 +73,8 @@ export const SkeletonTableFindings = () => {
<tr className="border-border-neutral-secondary border-b">
{/* Notification - empty header */}
<th className="w-6 py-3" />
{/* Expand - empty header */}
<th className="w-8 py-3" />
{/* Checkbox */}
<th className="w-10 px-2 py-3">
<div className="bg-bg-input-primary border-border-input-primary size-6 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
@@ -93,25 +87,17 @@ export const SkeletonTableFindings = () => {
<th className="w-[300px] px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Resource name */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-24 rounded" />
</th>
{/* Severity */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Provider */}
{/* Providers */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
<Skeleton className="h-4 w-16 rounded" />
</th>
{/* Service */}
{/* Resources */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
{/* Time */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-10 rounded" />
<Skeleton className="h-4 w-16 rounded" />
</th>
{/* Actions - empty header */}
<th className="w-10 py-3" />
@@ -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<InvitationProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Eye />}
label="Check Details"
+15 -4
View File
@@ -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([]);
@@ -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<ProviderProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Account Group"
@@ -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 ? (
<div className="flex min-h-[220px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<TreeSpinner className="size-6" />
<Spinner className="size-6" />
<p className="text-sm font-medium">Launching scans...</p>
</div>
</div>
@@ -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 && (
<div className="flex min-h-[220px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<TreeSpinner className="size-6" />
<Spinner className="size-6" />
<p className="text-sm font-medium">Gathering AWS Accounts...</p>
</div>
</div>
@@ -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({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-text-neutral-secondary" />
</Button>
}
>
<ActionDropdown>
{isOrgKind && (
<>
<ActionDropdownItem
@@ -304,13 +296,7 @@ export function DataTableRowActions({
return (
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-text-neutral-secondary" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Rocket />}
label={loading ? "Testing..." : `Test Connection${bulkCount}`}
@@ -399,13 +385,7 @@ export function DataTableRowActions({
/>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-text-neutral-secondary" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Provider Alias"
@@ -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 (
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<TreeSpinner className="size-6" />
<Spinner className="size-6" />
<p className="text-sm font-medium">Launching scans...</p>
</div>
</div>
@@ -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 (
<>
<div className="flex items-center justify-end">
<ActionDropdown
trigger={
<button
type="button"
aria-label="Resource actions"
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
>
<MoreVertical className="text-text-neutral-secondary size-5" />
</button>
}
ariaLabel="Resource actions"
>
<ActionDropdown ariaLabel="Resource actions">
<ActionDropdownItem
icon={<Eye className="size-5" />}
label="View Details"
@@ -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<RoleProps>({
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Role"
+1 -14
View File
@@ -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,
}: {
@@ -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<ScanProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Download />}
label="Download .zip"
+1 -1
View File
@@ -61,7 +61,7 @@ function Checkbox({
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
{indeterminate ? (
{indeterminate || checked === "indeterminate" ? (
<MinusIcon className={sizeStyles.icon} />
) : (
<CheckIcon className={sizeStyles.icon} />
+2
View File
@@ -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}
@@ -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({
<button
type="button"
aria-label={ariaLabel}
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
className={ACTION_TRIGGER_STYLES[variant]}
>
<MoreHorizontal className="text-text-neutral-secondary size-5" />
<EllipsisVertical className="text-text-neutral-secondary size-6" />
</button>
)}
</DropdownMenuTrigger>
@@ -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 (
<div className={cn("flex flex-col gap-1", className)}>
<span className="text-text-neutral-tertiary text-xs font-bold">
{labelContent}
</span>
<span className={labelClassName}>{labelContent}</span>
{variant === "simple" ? (
<div className="text-text-neutral-primary text-sm break-all">
{children}
</div>
) : variant === "transparent" ? (
) : variant === "transparent" || variant === "compact" ? (
<div className="text-text-neutral-primary text-sm">{children}</div>
) : (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary rounded-lg border px-3 py-2 text-sm">
+51
View File
@@ -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 (
<svg
className={cn("size-5 shrink-0 animate-spin", className)}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Loading"
>
{/* Background track */}
<circle
cx="10"
cy="10"
r="7.5"
className="stroke-button-primary/20"
strokeWidth="2.5"
fill="none"
/>
{/* Animated arc */}
<circle
cx="10"
cy="10"
r="7.5"
className="stroke-button-primary"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeDasharray="47.12"
strokeDashoffset="35.34"
/>
</svg>
);
}
@@ -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]";
}
+43 -5
View File
@@ -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,
@@ -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 (
<svg
className={cn("size-5 shrink-0 animate-spin", className)}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Loading"
>
{/* Background track */}
<circle
cx="10"
cy="10"
r="7.5"
className="stroke-button-primary/20"
strokeWidth="2.5"
fill="none"
/>
{/* Animated arc */}
<circle
cx="10"
cy="10"
r="7.5"
className="stroke-button-primary"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeDasharray="47.12"
strokeDashoffset="35.34"
/>
</svg>
);
}
// 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";
@@ -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 (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<Loader2 className="size-6 animate-spin" />
<Spinner className="size-6" />
<p className="text-text-neutral-secondary text-sm">
Fetching CloudTrail events...
</p>
@@ -181,7 +181,7 @@ export const EventsTimeline = ({
</span>
</label>
<div className="flex items-center gap-2">
{isPending && <Loader2 className="size-4 animate-spin" />}
{isPending && <Spinner className="size-4" />}
<span className="text-text-neutral-tertiary text-xs">
{events.length} event{events.length !== 1 && "s"}
</span>
+12 -2
View File
@@ -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 (
<div
className={cn(
"bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary flex w-fit min-w-0 items-center gap-1.5 border-2 px-2 py-0.5 text-xs",
multiline ? "h-auto rounded-lg" : "h-6 rounded-full",
"flex w-fit min-w-0 items-center gap-1.5 text-xs",
transparent
? "text-text-neutral-tertiary border-0 bg-transparent px-0 py-0"
: "text-text-neutral-primary bg-bg-neutral-tertiary border-border-neutral-tertiary border-2 px-2 py-0.5",
multiline
? "h-auto rounded-lg"
: transparent
? "h-auto"
: "h-6 rounded-full",
className,
)}
>
+3 -1
View File
@@ -37,7 +37,9 @@ export const DateWithTime = ({
<div
className={cn(
"gap-1",
inline ? "inline-flex flex-row items-center" : "flex flex-col",
inline
? "inline-flex flex-row flex-wrap items-center"
: "flex flex-col",
)}
>
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
+12 -1
View File
@@ -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 && <div className="shrink-0">{renderedIcon}</div>}
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
{nameIcon && (
<span className="text-text-neutral-tertiary shrink-0">
{nameIcon}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate font-medium">
@@ -64,7 +75,7 @@ export const EntityInfo = ({
{entityId && (
<div className="flex min-w-0 items-center gap-1">
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
UID:
{idLabel}:
</span>
<CodeSnippet
value={entityId}
+76 -26
View File
@@ -1,10 +1,16 @@
"use client";
import { LoaderCircleIcon, SearchIcon } from "lucide-react";
import { LoaderCircleIcon, SearchIcon, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useId, useRef, useState } from "react";
import { Badge } from "@/components/shadcn/badge/badge";
import { Input } from "@/components/shadcn/input/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { cn } from "@/lib/utils";
@@ -21,12 +27,17 @@ interface DataTableSearchProps {
*/
controlledValue?: string;
onSearchChange?: (value: string) => 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 (
<div
className={cn(
"relative flex items-center transition-all duration-300 ease-in-out",
isExpanded ? "w-64" : "w-10",
effectiveExpanded ? (hasBadge ? "w-[28rem]" : "w-64") : "w-10",
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -180,39 +196,73 @@ export const DataTableSearch = ({
onClick={handleIconClick}
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary absolute left-0 flex size-10 items-center justify-center rounded-md border transition-opacity duration-200",
isExpanded ? "pointer-events-none opacity-0" : "opacity-100",
effectiveExpanded ? "pointer-events-none opacity-0" : "opacity-100",
)}
aria-label="Open search"
>
<SearchIcon className="text-text-neutral-tertiary size-4" />
</button>
{/* Expanded state - full input */}
{/* Expanded state - full input with optional badge */}
<div
className={cn(
"relative w-full transition-opacity duration-200",
isExpanded ? "opacity-100" : "pointer-events-none opacity-0",
effectiveExpanded ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<SearchIcon className="text-text-neutral-tertiary size-4" />
</div>
<Input
ref={inputRef}
id={id}
type="search"
placeholder="Search..."
value={value}
onChange={(e) => 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 && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<LoaderCircleIcon className="text-text-neutral-tertiary size-4 animate-spin" />
<div
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
isFocused && "border-border-input-primary-pressed",
)}
>
<div className="flex shrink-0 items-center pl-3">
<SearchIcon className="text-text-neutral-tertiary size-4" />
</div>
)}
{hasBadge && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="tag"
className="max-w-[200px] shrink-0 cursor-default gap-1 truncate"
>
<span className="truncate">{badge.label}</span>
<button
type="button"
aria-label="Dismiss filter"
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
onClick={(e) => {
e.stopPropagation();
badge.onDismiss();
}}
>
<X className="size-3" />
</button>
</Badge>
</TooltipTrigger>
<TooltipContent>{badge.label}</TooltipContent>
</Tooltip>
)}
<Input
ref={inputRef}
id={id}
type="search"
placeholder={placeholder}
value={value}
onChange={(e) => 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 && (
<div className="flex shrink-0 items-center pr-3">
<LoaderCircleIcon className="text-text-neutral-tertiary size-4 animate-spin" />
</div>
)}
</div>
</div>
</div>
);
+25 -14
View File
@@ -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<TData, TValue> {
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<TData>) => React.ReactNode;
/** Badge shown inside the search input (e.g., active drill-down group) */
searchBadge?: { label: string; onDismiss: () => void };
}
export function DataTable<TData, TValue>({
@@ -121,6 +127,9 @@ export function DataTable<TData, TValue>({
onPageChange,
onPageSizeChange,
isLoading = false,
searchPlaceholder,
renderAfterRow,
searchBadge,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -213,6 +222,8 @@ export function DataTable<TData, TValue>({
paramPrefix={paramPrefix}
controlledValue={controlledSearch}
onSearchChange={onSearchChange}
placeholder={searchPlaceholder}
badge={searchBadge}
/>
)}
</div>
@@ -262,19 +273,19 @@ export function DataTable<TData, TValue>({
isSomeSelected={row.getIsSomeSelected()}
/>
) : (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
<Fragment key={row.id}>
<TableRow data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{renderAfterRow?.(row)}
</Fragment>
),
)
) : (
+8 -3
View File
@@ -13,7 +13,7 @@ const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
<table
ref={ref}
className={cn(
"w-full caption-bottom border-separate border-spacing-y-1 text-sm",
"w-full caption-bottom border-separate border-spacing-y-4 text-sm",
className,
)}
{...props}
@@ -39,7 +39,11 @@ const TableBody = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("", className)} {...props} />
<tbody
ref={ref}
className={cn("[&>tr:last-child>td]:after:hidden", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
@@ -105,7 +109,8 @@ const TableCell = forwardRef<
<td
ref={ref}
className={cn(
"px-1.5 py-2 align-middle first:pl-3 last:pr-3 [&:has([role=checkbox])]:pr-0",
"relative px-1.5 py-2 align-middle first:pl-3 last:pr-3 [&:has([role=checkbox])]:pr-0",
"after:bg-border-input-primary after:absolute after:right-0 after:-bottom-2 after:left-0 after:h-px",
className,
)}
{...props}
@@ -3,8 +3,6 @@
import { Row } from "@tanstack/react-table";
import { Pencil, Trash2 } from "lucide-react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
@@ -31,13 +29,7 @@ export function DataTableRowActions({
return (
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Pencil />}
label="Edit API Key"
@@ -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();
@@ -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<UserProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
}
>
<ActionDropdown>
<ActionDropdownItem
icon={<Pencil />}
label="Edit User"
+1
View File
@@ -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";
+384
View File
@@ -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<string, unknown>) {
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 }));
});
});
});
+206
View File
@@ -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<string, string | string[] | undefined>;
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<HTMLElement | null>;
}
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<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(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 };
}
+12
View File
@@ -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, []);
}
+56
View File
@@ -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" : ""}`;
}
+1 -1
View File
@@ -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,
},
+89
View File
@@ -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 "🌐";
}
+3
View File
@@ -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 ===== */
+61
View File
@@ -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;
}
+1
View File
@@ -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";