Compare commits

...

35 Commits

Author SHA1 Message Date
alejandrobailo
9f4063a6f1 fix(ui): restore default muted filter in findings menu link 2026-03-20 15:08:40 +01:00
alejandrobailo
d8a5127943 fix(ui): restore default muted filter on findings page 2026-03-20 14:37:54 +01:00
alejandrobailo
895fb80ea5 feat(ui): send finding context to Lighthouse chat 2026-03-20 14:37:50 +01:00
alejandrobailo
91fe7bc3c4 fix(ui): update muted indicator to match Figma design 2026-03-20 14:37:46 +01:00
alejandrobailo
8822663e7f fix(ui): allow drill-down for single-resource finding groups 2026-03-20 14:37:43 +01:00
alejandrobailo
83950d53f0 fix(ui): prevent actions dropdown from opening resource drawer 2026-03-20 14:37:40 +01:00
alejandrobailo
dce87293d9 refactor(ui): remove muted indicator from finding group rows 2026-03-20 13:53:14 +01:00
alejandrobailo
df3bd045b4 fix(ui): resolve finding IDs before muting in grouped findings view 2026-03-20 12:03:47 +01:00
alejandrobailo
62e92e3f85 refactor(ui): stabilize resource detail drawer with check-level metadata 2026-03-20 10:42:24 +01:00
alejandrobailo
fb94b24006 feat(ui): search finding groups by check title instead of check ID 2026-03-20 10:42:20 +01:00
alejandrobailo
88e89aa83a fix(ui): rename grouped findings table columns to Impacted Providers and Impacted Resources 2026-03-20 10:42:14 +01:00
alejandrobailo
f884432007 refactor(ui): comment out unused muted filter constants 2026-03-18 20:02:32 +01:00
alejandrobailo
31357d8186 style(ui): auto-format lint fixes 2026-03-18 20:01:03 +01:00
alejandrobailo
3026bdc5d5 feat(ui): redesign resource detail drawer with card layout 2026-03-18 20:00:59 +01:00
alejandrobailo
2cbb83fd93 feat(ui): add region flag emoji mapping utility 2026-03-18 20:00:54 +01:00
alejandrobailo
d7e2cb3ffb feat(ui): add transparent variant to CodeSnippet and idLabel to EntityInfo 2026-03-18 20:00:51 +01:00
alejandrobailo
aa8d2ca41d chore(ui): remove debug console.log from finding-groups actions 2026-03-18 20:00:47 +01:00
alejandrobailo
dcfa3db026 refactor(ui): inline tabs constants and adjust trigger styles 2026-03-18 19:31:56 +01:00
alejandrobailo
6019c7fb59 style(ui): update resource card labels to match Figma specs 2026-03-18 19:31:51 +01:00
alejandrobailo
378a1562f9 style(ui): auto-format lint fixes 2026-03-18 19:08:05 +01:00
alejandrobailo
54b5679868 feat(ui): sort provider selector alphabetically 2026-03-18 19:08:01 +01:00
alejandrobailo
2e28c7c54c refactor(ui): use VerticalDotsIcon as default ActionDropdown trigger 2026-03-18 19:07:56 +01:00
alejandrobailo
6efd9d7169 refactor(ui): use Badge component in ImpactedResourcesCell 2026-03-18 19:07:51 +01:00
alejandrobailo
5d31b5d5f9 fix(ui): collapse drill-down when filters change 2026-03-18 19:07:46 +01:00
alejandrobailo
eebb09503d feat(ui): add resource detail drawer for finding group drill-down 2026-03-18 19:07:41 +01:00
alejandrobailo
776a5a443e feat(ui): add loading transition and TreeSpinner to drill-down
- Show opacity transition on groups table when drilling down
- Use TreeSpinner with "Loading resources..." text in drill-down view
- Match provider wizard spinner style with button-primary color

Ref: PROWLER-881
2026-03-18 14:35:06 +01:00
alejandrobailo
8ec55b757f refactor(ui): consolidate columns into single select column
- Merge notification, expand/childIcon, and checkbox into one combined column
- Follow DataTableExpandableCell layout pattern with flex gap-2
- Use Checkbox size=sm with stopPropagation matching providers table

Ref: PROWLER-881
2026-03-18 14:35:00 +01:00
alejandrobailo
89a717c2cb style(ui): adjust provider icons in ImpactedProvidersCell
- Set icon size to 28x28
- Remove background and rounded styles from icon containers

Ref: PROWLER-881
2026-03-18 14:19:00 +01:00
alejandrobailo
a977e03176 feat(ui): add search placeholder prop to DataTable
- Add configurable placeholder prop to DataTableSearch
- Pass searchPlaceholder through DataTable component
- Set "Search by Check ID" for findings group table

Ref: PROWLER-881
2026-03-18 14:18:52 +01:00
alejandrobailo
05a1916f5a feat(ui): add resource selection and mute actions to drill-down
- Add checkboxes with selection state to resource rows
- Add FloatingMuteButton for bulk mute on selected resources
- Add per-row mute action dropdown with multi-select support

Ref: PROWLER-881
2026-03-18 14:18:46 +01:00
alejandrobailo
c296e2fd05 fix(ui): disable unsupported filter[muted] for finding-groups
- Remove filter[muted]=false injection from use-url-filters hook
- Clean sidebar link to navigate to /findings without muted filter
- Adapt row actions to support both FindingProps and FindingGroupRow shapes

Ref: PROWLER-881
2026-03-18 14:07:13 +01:00
alejandrobailo
9a85906db5 feat(ui): connect findings page to finding-groups endpoint
- Replace getFindings with getFindingGroups in findings page
- Update skeleton columns to match new table layout
- Update exports and selection context for new components

Ref: PROWLER-881
2026-03-18 14:07:06 +01:00
alejandrobailo
f0d1ec8edb feat(ui): add findings group table with drill-down view
- Add FindingsGroupTable as main wrapper with selection support
- Add FindingsGroupDrillDown with sticky header and infinite scroll
- Add column definitions for finding groups and resources
- Add ImpactedProvidersCell component

Ref: PROWLER-881
2026-03-18 14:06:59 +01:00
alejandrobailo
fec30d9f4e feat(ui): add finding-groups server actions and adapter
- Create server actions for finding-groups and resources endpoints
- Add adapter to transform API responses to table row shapes
- Map filter[search] to filter[check_id__icontains] for search support

Ref: PROWLER-881
2026-03-18 14:06:53 +01:00
alejandrobailo
c6c5bc655f feat(ui): add finding-groups types and drill-down store
- Add FindingGroupRow and FindingResourceRow discriminated union types
- Add type guards and const discriminators following providers-table pattern
- Add Zustand store for drill-down state management

Ref: PROWLER-881
2026-03-18 14:06:43 +01:00
37 changed files with 3202 additions and 177 deletions

View File

@@ -0,0 +1,128 @@
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 FindingGroupApiItem {
type: "finding-groups";
id: string;
attributes: {
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;
};
}
/**
* 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 FindingGroupResourceApiItem {
type: "finding-group-resources";
id: string;
attributes: {
resource: {
uid: string;
name: string;
service: string;
region: string;
type: string;
};
provider: {
type: string;
uid: string;
alias: string;
};
status: string;
severity: string;
first_seen_at: string | null;
last_seen_at: string | null;
};
}
/**
* 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 || "-",
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",
mutedReason: undefined,
firstSeenAt: item.attributes.first_seen_at,
lastSeenAt: item.attributes.last_seen_at,
}));
}

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;
}
};

View File

@@ -0,0 +1,2 @@
export * from "./finding-groups";
export * from "./finding-groups.adapter";

View File

@@ -0,0 +1,168 @@
import { createDict } from "@/lib";
import { ProviderType, Severity } from "@/types";
/**
* 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
resourceUid: string;
resourceName: string;
resourceService: string;
resourceRegion: string;
resourceType: string;
// Provider
providerType: ProviderType;
providerAlias: string;
providerUid: string;
// Check metadata (flattened)
risk: string;
description: string;
statusExtended: string;
complianceFrameworks: string[];
categories: string[];
remediation: {
recommendation: { text: string; url: string };
code: {
cli: string;
other: string;
nativeiac: string;
terraform: string;
};
};
additionalUrls: string[];
}
/**
* Extracts unique compliance framework names from available data.
*
* Supports two shapes:
* 1. check_metadata.compliance — array of { Framework, Version, ... } objects
* e.g. [{ Framework: "CIS-AWS", Version: "1.4" }, { Framework: "PCI-DSS" }]
* 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 1: 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 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);
}
/**
* 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
resourceUid: resourceAttrs.uid || "-",
resourceName: resourceAttrs.name || "-",
resourceService: resourceAttrs.service || "-",
resourceRegion: resourceAttrs.region || "-",
resourceType: resourceAttrs.type || "-",
// 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 || [],
};
});
}

View File

@@ -0,0 +1,95 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
/**
* Resolves resource UIDs + check ID into actual finding UUIDs.
* Uses /findings/latest with check_id and resource_uid__in filters
* to batch-resolve in a single API call.
*/
export const resolveFindingIds = async ({
checkId,
resourceUids,
}: {
checkId: string;
resourceUids: string[];
}): Promise<string[]> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings/latest`);
url.searchParams.append("filter[check_id]", checkId);
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
url.searchParams.append("page[size]", resourceUids.length.toString());
try {
const response = await fetch(url.toString(), { headers });
const data = await handleApiResponse(response);
if (!data?.data || !Array.isArray(data.data)) return [];
return data.data.map((item: { id: string }) => item.id);
} 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,
}: {
checkIds: string[];
}): Promise<string[]> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings/latest`);
url.searchParams.append("filter[check_id__in]", checkIds.join(","));
url.searchParams.append("page[size]", "500");
try {
const response = await fetch(url.toString(), { headers });
const data = await handleApiResponse(response);
if (!data?.data || !Array.isArray(data.data)) return [];
return data.data.map((item: { id: string }) => item.id);
} catch (error) {
console.error("Error resolving finding IDs by check IDs:", 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;
}
};

View File

@@ -1 +1,3 @@
export * from "./findings";
export * from "./findings-by-resource";
export * from "./findings-by-resource.adapter";

View File

@@ -188,7 +188,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;

View File

@@ -1,9 +1,12 @@
import { Suspense } from "react";
import {
adaptFindingGroupsResponse,
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import {
getFindingById,
getFindings,
getLatestFindings,
getLatestMetadataInfo,
getMetadataInfo,
} from "@/actions/findings";
@@ -12,13 +15,12 @@ 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,
@@ -60,13 +62,12 @@ export default async function Findings({
: Promise.resolve(null),
]);
// Process the finding data to match the expected structure
// Process the finding data to match the expected structure (for detail sheet)
const processedFinding = findingByIdData?.data
? (() => {
const finding = findingByIdData.data;
const included = findingByIdData.included || [];
// Build dictionaries from included data
type IncludedItem = {
type: string;
id: string;
@@ -179,65 +180,40 @@ 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);
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 data={groups} metadata={findingGroupsData?.meta} />
</>
);
};

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>

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>
);
};

View File

@@ -0,0 +1,185 @@
"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;
}
export function getColumnFindingGroups({
rowSelection,
selectableRowCount,
onDrillDown,
}: 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 delta =
group.newCount > 0
? DeltaValues.NEW
: group.changedCount > 0
? DeltaValues.CHANGED
: DeltaValues.NONE;
return (
<div className="flex items-center gap-2">
<NotificationIndicator delta={delta} />
<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="text-text-neutral-secondary size-4" />
</button>
<Checkbox
size="sm"
checked={!!rowSelection[row.id]}
onCheckedChange={(checked) =>
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 }) => <StatusFindingBadge status={row.original.status} />,
enableSorting: false,
},
// Finding title column
{
accessorKey: "finding",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Finding"
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,
},
];
}

View File

@@ -0,0 +1,317 @@
"use client";
import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table";
import { CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
import { useContext, useState } from "react";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { VerticalDotsIcon } from "@/components/icons";
import { Checkbox } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { DateWithTime } from "@/components/ui/entities";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { SeverityBadge } from "@/components/ui/table";
import { FindingResourceRow } from "@/types";
import { FindingsSelectionContext } from "./findings-selection-context";
import { NotificationIndicator } from "./notification-indicator";
/**
* Computes a human-readable "failing for" duration from first_seen_at to now.
* Returns null if the resource is not failing or has no first_seen_at.
*/
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" : ""}`;
}
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 } = 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([]);
};
return (
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
/>
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<ActionDropdown
trigger={
<button
type="button"
aria-label="Resource actions"
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
>
<VerticalDotsIcon
size={20}
className="text-text-neutral-secondary"
/>
</button>
}
ariaLabel="Resource actions"
>
<ActionDropdownItem
icon={
resource.isMuted ? (
<VolumeOff className="size-5" />
) : isResolving ? (
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<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: () => (
<span className="text-text-neutral-secondary text-sm font-medium">
Resource
</span>
),
cell: ({ row }) => (
<EntityInfo
entityAlias={row.original.resourceName}
entityId={row.original.resourceUid}
/>
),
enableSorting: false,
},
// Service
{
id: "service",
header: () => (
<span className="text-text-neutral-secondary text-sm font-medium">
Service
</span>
),
cell: ({ row }) => (
<p className="text-text-neutral-primary max-w-[100px] truncate text-sm">
{row.original.service}
</p>
),
enableSorting: false,
},
// Region
{
id: "region",
header: () => (
<span className="text-text-neutral-secondary text-sm font-medium">
Region
</span>
),
cell: ({ row }) => (
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{row.original.region}
</p>
),
enableSorting: false,
},
// Severity
{
id: "severity",
header: () => (
<span className="text-text-neutral-secondary text-sm font-medium">
Severity
</span>
),
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
enableSorting: false,
},
// Account — alias + uid (EntityInfo with provider logo)
{
id: "account",
header: () => (
<span className="text-text-neutral-secondary text-sm font-medium">
Account
</span>
),
cell: ({ row }) => (
<EntityInfo
cloudProvider={row.original.providerType}
entityAlias={row.original.providerAlias}
entityId={row.original.providerUid}
/>
),
enableSorting: false,
},
// Last seen
{
id: "lastSeen",
header: () => (
<span className="text-text-neutral-secondary text-sm font-medium">
Last seen
</span>
),
cell: ({ row }) => <DateWithTime dateTime={row.original.lastSeenAt} />,
enableSorting: false,
},
// Failing for — duration since first_seen_at
{
id: "failingFor",
header: () => (
<span className="text-text-neutral-secondary text-sm font-medium">
Failing for
</span>
),
cell: ({ row }) => {
const label = getFailingForLabel(row.original.firstSeenAt);
return (
<p className="text-text-neutral-primary text-sm">{label || "-"}</p>
);
},
enableSorting: false,
},
// Actions column — mute only
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => <ResourceRowActions row={row} />,
enableSorting: false,
},
];
}

View File

@@ -18,12 +18,36 @@ 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;
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 +64,61 @@ 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);
// 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 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];
};
const getMuteLabel = () => {
if (isMuted) return "Muted";
const ids = getMuteIds();
const ids = getDisplayIds();
if (ids.length > 1) {
return `Mute ${ids.length} Findings`;
}
return "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);
}
};
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
clearSelection();
setResolvedIds([]);
if (onMuteComplete) {
onMuteComplete(getMuteIds());
onMuteComplete(getDisplayIds());
return;
}
@@ -100,7 +137,7 @@ export function DataTableRowActions<T extends FindingRowData>({
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={getMuteIds()}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
/>
@@ -124,15 +161,15 @@ export function DataTableRowActions<T extends FindingRowData>({
icon={
isMuted ? (
<VolumeOff className="size-5" />
) : isResolving ? (
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<VolumeX className="size-5" />
)
}
label={getMuteLabel()}
disabled={isMuted}
onSelect={() => {
setIsMuteModalOpen(true);
}}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={isMuted || isResolving}
onSelect={handleMuteClick}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}

View File

@@ -0,0 +1,362 @@
"use client";
import {
flexRender,
getCoreRowModel,
Row,
RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { ChevronLeft, VolumeX } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Button } from "@/components/shadcn";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-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 { MuteFindingsModal } from "../mute-findings-modal";
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 router = useRouter();
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 = useMemo(
() => Object.fromEntries(searchParams.entries()),
[searchParams],
);
const hasDateOrScan = hasDateOrScanFilter(currentParams);
// Stabilize filters object — only recompute when searchParams change
const filters = useMemo(() => {
const result: Record<string, string> = {};
searchParams.forEach((value, key) => {
if (key.startsWith("filter[") || key.includes("__in")) {
result[key] = value;
}
});
return result;
}, [searchParams]);
const handleSetResources = useCallback(
(newResources: FindingResourceRow[], _hasMore: boolean) => {
setResources(newResources);
setIsLoading(false);
},
[],
);
const handleAppendResources = useCallback(
(newResources: FindingResourceRow[], _hasMore: boolean) => {
setResources((prev) => [...prev, ...newResources]);
setIsLoading(false);
},
[],
);
const handleSetLoading = useCallback((loading: boolean) => {
setIsLoading(loading);
}, []);
const { sentinelRef } = useInfiniteResources({
checkId: group.checkId,
hasDateOrScanFilter: hasDateOrScan,
filters,
onSetResources: handleSetResources,
onAppendResources: handleAppendResources,
onSetLoading: handleSetLoading,
});
// Resource detail drawer
const drawer = useResourceDetailDrawer({
resources,
checkId: group.checkId,
});
const handleDrawerMuteComplete = () => {
drawer.closeDrawer();
router.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);
// Mute modal state — resource IDs resolved to finding UUIDs on-click
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [resolvedFindingIds, setResolvedFindingIds] = useState<string[]>([]);
const [isResolvingIds, setIsResolvingIds] = useState(false);
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
const resolveResourceIds = useCallback(
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,
});
},
[resources, group.checkId],
);
const handleMuteClick = async () => {
setIsResolvingIds(true);
const findingIds = await resolveResourceIds(selectedFindingIds);
setResolvedFindingIds(findingIds);
setIsResolvingIds(false);
if (findingIds.length > 0) {
setIsMuteModalOpen(true);
}
};
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();
setResolvedFindingIds([]);
router.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,
}}
>
<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>
<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">
<TreeSpinner 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 && (
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={resolvedFindingIds}
onComplete={handleMuteComplete}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={handleMuteClick}
disabled={isResolvingIds}
size="lg"
className="shadow-lg"
>
{isResolvingIds ? (
<TreeSpinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedFindingIds.length})
</Button>
</div>
</>
)}
<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>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { VolumeX } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { resolveFindingIdsByCheckIds } from "@/actions/findings/findings-by-resource";
import { Button } from "@/components/shadcn";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import { DataTable } from "@/components/ui/table";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { MuteFindingsModal } from "../mute-findings-modal";
import { getColumnFindingGroups } from "./column-finding-groups";
import { FindingsGroupDrillDown } from "./findings-group-drill-down";
import { FindingsSelectionContext } from "./findings-selection-context";
interface FindingsGroupTableProps {
data: FindingGroupRow[];
metadata?: MetaDataProps;
}
export function FindingsGroupTable({
data,
metadata,
}: FindingsGroupTableProps) {
const router = useRouter();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
null,
);
const [isDrillingDown, setIsDrillingDown] = useState(false);
// Track finding group IDs to detect data changes (e.g., after muting)
const currentIds = (data ?? []).map((g) => g.id).join(",");
const previousIdsRef = useRef(currentIds);
// Reset selection when page changes
useEffect(() => {
setRowSelection({});
}, [metadata?.pagination?.page]);
// Reset selection and collapse drill-down when data changes (e.g., filters)
useEffect(() => {
if (previousIdsRef.current !== currentIds) {
setRowSelection({});
setExpandedCheckId(null);
setExpandedGroup(null);
previousIdsRef.current = currentIds;
}
}, [currentIds]);
const safeData = data ?? [];
// Get selected group IDs (only non-fully-muted groups 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 (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 selectedFindingIds.includes(id);
};
// Mute modal state — check IDs resolved to finding UUIDs on-click
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [resolvedFindingIds, setResolvedFindingIds] = useState<string[]>([]);
const [isResolvingIds, setIsResolvingIds] = useState(false);
const handleMuteClick = async () => {
setIsResolvingIds(true);
const findingIds = await resolveFindingIdsByCheckIds({
checkIds: selectedFindingIds,
});
setResolvedFindingIds(findingIds);
setIsResolvingIds(false);
if (findingIds.length > 0) {
setIsMuteModalOpen(true);
}
};
/** Shared resolver for row action dropdowns (via context). */
const resolveMuteIds = useCallback(
async (checkIds: string[]) => resolveFindingIdsByCheckIds({ checkIds }),
[],
);
const handleMuteComplete = () => {
clearSelection();
setResolvedFindingIds([]);
router.refresh();
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
setIsDrillingDown(true);
setRowSelection({});
// Brief loading state before switching to drill-down view
setTimeout(() => {
setExpandedCheckId(checkId);
setExpandedGroup(group);
setIsDrillingDown(false);
}, 150);
};
const handleCollapse = () => {
setExpandedCheckId(null);
setExpandedGroup(null);
};
const columns = getColumnFindingGroups({
rowSelection,
selectableRowCount,
onDrillDown: handleDrillDown,
});
// Drill-down mode: show sticky header + resources table
if (expandedCheckId && expandedGroup) {
return (
<FindingsGroupDrillDown
group={expandedGroup}
onCollapse={handleCollapse}
/>
);
}
// Normal mode: show finding groups table
return (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds,
selectedFindings,
clearSelection,
isSelected,
resolveMuteIds,
}}
>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
searchPlaceholder="Search by Check ID"
isLoading={isDrillingDown}
/>
{selectedFindingIds.length > 0 && (
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={resolvedFindingIds}
onComplete={handleMuteComplete}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={handleMuteClick}
disabled={isResolvingIds}
size="lg"
className="shadow-lg"
>
{isResolvingIds ? (
<TreeSpinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedFindingIds.length})
</Button>
</div>
</>
)}
</FindingsSelectionContext.Provider>
);
}

View File

@@ -2,13 +2,13 @@
import { createContext, useContext } from "react";
import { FindingProps } from "@/types";
interface FindingsSelectionContextValue {
selectedFindingIds: string[];
selectedFindings: FindingProps[];
selectedFindings: any[];
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[]>;
}
export const FindingsSelectionContext =

View File

@@ -0,0 +1,68 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { ProviderType } from "@/types";
import { PROVIDER_ICONS } from "./provider-icon-cell";
const MAX_VISIBLE_PROVIDERS = 3;
const ICON_SIZE = 28;
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) => {
const IconComponent = PROVIDER_ICONS[provider];
if (!IconComponent) {
return (
<div
key={provider}
className="flex size-7 items-center justify-center"
>
<span className="text-text-neutral-secondary text-xs">?</span>
</div>
);
}
return (
<div
key={provider}
className="flex size-7 items-center justify-center overflow-hidden"
>
<IconComponent width={ICON_SIZE} height={ICON_SIZE} />
</div>
);
})}
{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>
);
};

View File

@@ -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

View File

@@ -1,11 +1,15 @@
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";
export * from "./impacted-providers-cell";
export * from "./impacted-resources-cell";
export * from "./notification-indicator";
export * from "./provider-icon-cell";
export * from "./skeleton-table-findings";

View File

@@ -0,0 +1,2 @@
export { ResourceDetailDrawer } from "./resource-detail-drawer";
export { useResourceDetailDrawer } from "./use-resource-detail-drawer";

View File

@@ -0,0 +1,671 @@
"use client";
import {
CircleArrowRight,
CircleChevronLeft,
CircleChevronRight,
VolumeOff,
VolumeX,
} from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import type { ResourceDrawerFinding } from "@/actions/findings";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { getComplianceIcon } from "@/components/icons";
import {
Badge,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import { Card } from "@/components/shadcn/card/card";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
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 { getRegionFlag } from "@/lib/region-flags";
import { cn } from "@/lib/utils";
import { Muted } from "../../muted";
import { NotificationIndicator } from "../notification-indicator";
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;
}
const MarkdownContainer = ({ children }: { children: string }) => (
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
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" : ""}`;
}
export function ResourceDetailDrawerContent({
isLoading,
isNavigating,
checkMeta,
currentIndex,
totalResources,
currentFinding,
otherFindings,
onNavigatePrev,
onNavigateNext,
onMuteComplete,
}: ResourceDetailDrawerContentProps) {
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
// Initial load — no check metadata yet
if (!checkMeta && isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16">
<TreeSpinner className="size-6" />
<span className="text-text-neutral-tertiary text-sm">
Loading finding details...
</span>
</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();
}}
/>
)}
{/* 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">
<div
className={cn(
"size-2 rounded-full",
f.delta === "new"
? "bg-system-severity-high"
: "bg-system-severity-low",
)}
/>
<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 items-center gap-2">
<span className="text-text-neutral-tertiary text-xs font-medium">
Compliance Frameworks:
</span>
<div className="flex items-center gap-1.5">
{checkMeta.complianceFrameworks.map((framework) => {
const icon = getComplianceIcon(framework);
return icon ? (
<Image
key={framework}
src={icon}
alt={framework}
width={20}
height={20}
className="shrink-0"
/>
) : (
<span
key={framework}
className="text-text-neutral-secondary text-xs"
>
{framework}
</span>
);
})}
</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="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="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 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
{/* Resource info — shows loading when currentFinding is not yet available */}
{!f || isNavigating ? (
<div className="flex items-center justify-center py-6">
<TreeSpinner className="size-5" />
</div>
) : (
<>
{/* Account, Resource, Service, Region, Actions */}
<div className="flex items-center justify-between">
<EntityInfo
cloudProvider={f.providerType}
entityAlias={f.providerAlias}
entityId={f.providerUid}
/>
<EntityInfo
entityAlias={f.resourceType}
entityId={f.resourceUid}
idLabel="UID"
/>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
Service
</span>
<span className="text-text-neutral-primary text-sm">
{f.resourceService}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
Region
</span>
<span className="text-text-neutral-primary flex items-center gap-1.5 text-sm">
{getRegionFlag(f.resourceRegion) && (
<span className="translate-y-px text-base leading-none">
{getRegionFlag(f.resourceRegion)}
</span>
)}
{f.resourceRegion}
</span>
</div>
<div>
<ActionDropdown 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)}
/>
</ActionDropdown>
</div>
</div>
{/* Dates row */}
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
Last detected
</span>
<DateWithTime inline dateTime={f.updatedAt || "-"} />
</div>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
First seen
</span>
<DateWithTime inline dateTime={f.firstSeenAt || "-"} />
</div>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
Failing for
</span>
<span className="text-text-neutral-primary text-sm">
{getFailingForLabel(f.firstSeenAt) || "-"}
</span>
</div>
</div>
{/* IDs row */}
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
Check ID
</span>
<CodeSnippet
value={checkMeta.checkId}
transparent
className="max-w-full text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
Finding ID
</span>
<CodeSnippet
value={f.id}
transparent
className="max-w-full text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-[10px]">
Finding UID
</span>
<CodeSnippet
value={f.uid}
transparent
className="max-w-full text-sm"
/>
</div>
</div>
</>
)}
{/* Tabs */}
<Tabs
defaultValue="overview"
className="flex min-h-0 w-full flex-1 flex-col"
>
<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>
</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">
<TreeSpinner 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>
</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: "linear-gradient(96deg, #2EE59B 3.55%, #62DFF0 98.85%)",
}}
>
<CircleArrowRight className="size-5" />
View This Finding With Lighthouse AI
</a>
</div>
);
}
function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
return (
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[finding.id]}
/>
<TableRow>
<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">
<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)}
/>
</ActionDropdown>
</TableCell>
</TableRow>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,182 @@
"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;
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;
}
/**
* 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,
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 fetchFindings = async (resourceUid: string) => {
// 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 });
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) {
console.error("Error fetching findings for resource:", error);
// Don't clear findings — keep previous data as fallback during navigation
} finally {
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 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 = () => {
if (currentIndex < resources.length - 1) {
navigateTo(currentIndex + 1);
// Pre-fetch more resources when nearing the end
if (currentIndex >= resources.length - 3) {
onRequestMoreResources?.();
}
}
};
// 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: resources.length,
currentFinding,
otherFindings,
allFindings: findings,
openDrawer,
closeDrawer,
navigatePrev,
navigateNext,
};
}

View File

@@ -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" />

View File

@@ -61,6 +61,7 @@ interface ChatProps {
providers: Provider[];
defaultProviderId?: LighthouseProvider;
defaultModelId?: string;
initialPrompt?: string;
}
interface SelectedModel {
@@ -102,6 +103,7 @@ export const Chat = ({
providers: initialProviders,
defaultProviderId,
defaultModelId,
initialPrompt,
}: ChatProps) => {
const { toast } = useToast();
@@ -306,6 +308,15 @@ export const Chat = ({
}
};
// Auto-send initial prompt from URL (e.g., finding context from drawer)
const initialPromptSentRef = useRef(false);
useEffect(() => {
if (initialPrompt && !initialPromptSentRef.current) {
initialPromptSentRef.current = true;
sendMessage({ text: initialPrompt });
}
}, [initialPrompt, sendMessage]);
// Handlers
const handleNewChat = () => {
setMessages([]);

View File

@@ -1,8 +1,8 @@
"use client";
import { MoreHorizontal } from "lucide-react";
import { ComponentProps, ReactNode } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import {
@@ -41,7 +41,10 @@ export function ActionDropdown({
aria-label={ariaLabel}
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
>
<MoreHorizontal className="text-text-neutral-secondary size-5" />
<VerticalDotsIcon
size={20}
className="text-text-neutral-secondary"
/>
</button>
)}
</DropdownMenuTrigger>

View File

@@ -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]";
}

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,

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,
)}
>

View File

@@ -18,6 +18,8 @@ interface EntityInfoProps {
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;
@@ -33,6 +35,7 @@ export const EntityInfo = ({
entityAlias,
entityId,
badge,
idLabel = "UID",
showCopyAction = true,
}: EntityInfoProps) => {
const canCopy = Boolean(entityId && showCopyAction);
@@ -64,7 +67,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}

View File

@@ -21,12 +21,14 @@ interface DataTableSearchProps {
*/
controlledValue?: string;
onSearchChange?: (value: string) => void;
placeholder?: string;
}
export const DataTableSearch = ({
paramPrefix = "",
controlledValue,
onSearchChange,
placeholder = "Search...",
}: DataTableSearchProps) => {
const searchParams = useSearchParams();
const pathname = usePathname();
@@ -201,7 +203,7 @@ export const DataTableSearch = ({
ref={inputRef}
id={id}
type="search"
placeholder="Search..."
placeholder={placeholder}
value={value}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}

View File

@@ -96,6 +96,8 @@ 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;
}
export function DataTable<TData, TValue>({
@@ -121,6 +123,7 @@ export function DataTable<TData, TValue>({
onPageChange,
onPageSizeChange,
isLoading = false,
searchPlaceholder,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -213,6 +216,7 @@ export function DataTable<TData, TValue>({
paramPrefix={paramPrefix}
controlledValue={controlledSearch}
onSearchChange={onSearchChange}
placeholder={searchPlaceholder}
/>
)}
</div>

View File

@@ -0,0 +1,172 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import {
adaptFindingGroupResourcesResponse,
getFindingGroupResources,
getLatestFindingGroupResources,
} from "@/actions/finding-groups";
import { FindingResourceRow } from "@/types";
const RESOURCES_PAGE_SIZE = 20;
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;
}
interface UseInfiniteResourcesReturn {
sentinelRef: (node: HTMLDivElement | null) => 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. Only `checkId` triggers a new
* initial fetch via useEffect.
*/
export function useInfiniteResources({
checkId,
hasDateOrScanFilter,
filters,
onSetResources,
onAppendResources,
onSetLoading,
}: UseInfiniteResourcesOptions): UseInfiniteResourcesReturn {
// All mutable state in refs to break dependency chains
const pageRef = useRef(1);
const hasMoreRef = useRef(true);
const isLoadingRef = useRef(false);
const currentCheckIdRef = useRef(checkId);
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;
const fetchPage = useCallback(
async (page: number, append: boolean, forCheckId: string) => {
if (isLoadingRef.current) 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 checkId changed during fetch
if (currentCheckIdRef.current !== forCheckId) 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) {
console.error("Error fetching resources:", error);
if (currentCheckIdRef.current === forCheckId) {
onSetLoadingRef.current(false);
}
} finally {
isLoadingRef.current = false;
}
},
[], // No dependencies — reads everything from refs
);
// Fetch first page when checkId changes
useEffect(() => {
currentCheckIdRef.current = checkId;
pageRef.current = 1;
hasMoreRef.current = true;
isLoadingRef.current = false;
fetchPage(1, false, checkId);
}, [checkId, fetchPage]);
const loadNextPage = useCallback(() => {
if (!hasMoreRef.current || isLoadingRef.current) return;
const nextPage = pageRef.current + 1;
pageRef.current = nextPage;
fetchPage(nextPage, true, currentCheckIdRef.current);
}, [fetchPage]);
// IntersectionObserver callback — stable since loadNextPage is stable
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting) {
loadNextPage();
}
},
[loadNextPage],
);
// Set up observer when sentinel node changes
const sentinelRef = useCallback(
(node: HTMLDivElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (node) {
observerRef.current = new IntersectionObserver(handleIntersection, {
rootMargin: "200px",
});
observerRef.current.observe(node);
}
},
[handleIntersection],
);
// Cleanup on unmount
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
return { sentinelRef };
}

89
ui/lib/region-flags.ts Normal file
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 "🌐";
}

View File

@@ -0,0 +1,58 @@
import { create } from "zustand";
import { FindingGroupRow, FindingResourceRow } from "@/types";
interface FindingsDrillDownState {
expandedCheckId: string | null;
expandedGroup: FindingGroupRow | null;
resources: FindingResourceRow[];
resourcesPage: number;
hasMoreResources: boolean;
isLoadingResources: boolean;
drillDown: (checkId: string, group: FindingGroupRow) => void;
collapse: () => void;
setResources: (resources: FindingResourceRow[], hasMore: boolean) => void;
appendResources: (resources: FindingResourceRow[], hasMore: boolean) => void;
setLoadingResources: (loading: boolean) => void;
incrementPage: () => void;
}
const initialState = {
expandedCheckId: null,
expandedGroup: null,
resources: [],
resourcesPage: 1,
hasMoreResources: false,
isLoadingResources: false,
};
export const useFindingsDrillDownStore = create<FindingsDrillDownState>()(
(set) => ({
...initialState,
drillDown: (checkId, group) =>
set({
expandedCheckId: checkId,
expandedGroup: group,
resources: [],
resourcesPage: 1,
hasMoreResources: false,
isLoadingResources: true,
}),
collapse: () => set(initialState),
setResources: (resources, hasMore) =>
set({
resources,
hasMoreResources: hasMore,
isLoadingResources: false,
}),
appendResources: (newResources, hasMore) =>
set((state) => ({
resources: [...state.resources, ...newResources],
hasMoreResources: hasMore,
isLoadingResources: false,
})),
setLoadingResources: (loading) => set({ isLoadingResources: loading }),
incrementPage: () =>
set((state) => ({ resourcesPage: state.resourcesPage + 1 })),
}),
);

View File

@@ -0,0 +1,60 @@
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;
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;
}

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";