feat(ui): new findings view (#9794)

This commit is contained in:
Alejandro Bailo
2026-01-15 12:15:06 +01:00
committed by GitHub
parent 28978f6db6
commit 76cda6d777
15 changed files with 109 additions and 287 deletions

View File

@@ -1,4 +1,3 @@
import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import {
@@ -84,19 +83,20 @@ export default async function Findings({
return (
<ContentLayout title="Findings" icon="lucide:tag">
<FindingsFilters
providers={providersData?.data || []}
providerIds={providerIds}
providerDetails={providerDetails}
completedScans={completedScans || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
/>
<Spacer y={8} />
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerIds={providerIds}
providerDetails={providerDetails}
completedScans={completedScans || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
/>
</div>
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>

View File

@@ -91,11 +91,7 @@ export const createMuteRulesColumns = (
},
{
id: "actions",
header: () => (
<div className="flex items-center justify-center px-2">
<span className="text-sm font-semibold">Actions</span>
</div>
),
header: () => null,
cell: ({ row }) => {
return (
<MuteRuleRowActions

View File

@@ -1,12 +1,14 @@
"use client";
import { XCircle } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { Button } from "../shadcn";
// Filters that should be excluded from count and visibility check
const EXCLUDED_FILTERS = ["filter[search]", "filter[muted]"];
export interface ClearFiltersButtonProps {
className?: string;
text?: string;
@@ -23,22 +25,45 @@ export const ClearFiltersButton = ({
showCount = false,
variant = "link",
}: ClearFiltersButtonProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { clearAllFilters, hasFilters } = useUrlFilters();
// Count active filters (excluding search)
const filterCount = Array.from(searchParams.keys()).filter(
(key) => key.startsWith("filter[") && key !== "filter[search]",
).length;
// Get active filters (excluding search and muted)
const activeFilters = Array.from(searchParams.keys()).filter(
(key) => key.startsWith("filter[") && !EXCLUDED_FILTERS.includes(key),
);
if (!hasFilters()) {
const filterCount = activeFilters.length;
// Clear all filters except excluded ones (muted, search)
const clearFiltersPreservingExcluded = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
Array.from(params.keys()).forEach((key) => {
if (
(key.startsWith("filter[") && !EXCLUDED_FILTERS.includes(key)) ||
key === "sort"
) {
params.delete(key);
}
});
params.delete("page");
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [router, searchParams, pathname]);
// Only show button if there are filters other than the excluded ones
if (filterCount === 0) {
return null;
}
const displayText = showCount ? `Clear Filters (${filterCount})` : text;
return (
<Button aria-label={ariaLabel} onClick={clearAllFilters} variant={variant}>
<Button
aria-label={ariaLabel}
onClick={clearFiltersPreservingExcluded}
variant={variant}
>
<XCircle className="mr-0.5 size-4" />
{displayText}
</Button>

View File

@@ -1,7 +1,5 @@
"use client";
import { Spacer } from "@heroui/spacer";
import { FilterOption } from "@/types";
import { DataTableFilterCustom } from "../ui/table";
@@ -32,7 +30,7 @@ export const FilterControls = ({
customFilters,
}: FilterControlsProps) => {
return (
<div className="flex flex-col">
<div className="mb-4 flex flex-col">
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center">
<div className="grid w-full flex-1 grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
{search && <CustomSearchInput />}
@@ -45,7 +43,6 @@ export const FilterControls = ({
</div>
{customFilters && customFilters.length > 0 && (
<>
<Spacer y={8} />
<DataTableFilterCustom filters={customFilters} />
</>
)}

View File

@@ -256,6 +256,23 @@ export function getColumnFindings(
},
enableSorting: false,
},
// Region column
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
const regionText = typeof region === "string" ? region : "-";
return (
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{regionText}
</p>
);
},
enableSorting: false,
},
// TODO: PROWLER-379 - Enable Impacted Resources column when backend supports grouped findings
// {
// accessorKey: "impactedResources",

View File

@@ -147,7 +147,8 @@ export const FindingDetail = ({
{/* Row 3: First Seen */}
<div className="text-text-neutral-tertiary text-sm">
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
<span className="text-text-neutral-secondary mr-1">Time:</span>
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
</div>
</div>
@@ -188,6 +189,9 @@ export const FindingDetail = ({
<InfoField label="Finding UID" variant="simple">
<CodeSnippet value={attributes.uid} />
</InfoField>
<InfoField label="First seen" variant="simple">
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
</InfoField>
</div>
{attributes.status === "FAIL" && (

View File

@@ -34,12 +34,14 @@ const InfoField = ({
label: string;
children: React.ReactNode;
}) => (
<div className="flex flex-col gap-1">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-text-neutral-secondary text-xs font-bold">
{label}
</span>
<div className="border-border-input-primary bg-bg-input-primary flex items-center rounded-lg border p-3">
<span className="text-small text-text-neutral-primary">{children}</span>
<div className="border-border-input-primary bg-bg-input-primary flex min-w-0 items-center overflow-hidden rounded-lg border p-3">
<span className="text-small text-text-neutral-primary min-w-0 truncate">
{children}
</span>
</div>
</div>
);
@@ -87,18 +89,18 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
Share this link with the user:
</h3>
<div className="flex flex-col items-start justify-between">
<div className="flex w-full flex-col items-start justify-between overflow-hidden">
<Snippet
classNames={{
base: "mx-auto",
base: "w-full max-w-full",
content: "min-w-0 overflow-hidden",
pre: "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap",
}}
hideSymbol
variant="bordered"
className="bg-bg-neutral-secondary overflow-hidden py-1 text-ellipsis whitespace-nowrap"
className="bg-bg-neutral-secondary max-w-full overflow-hidden py-1"
>
<p className="no-scrollbar w-fit overflow-hidden overflow-x-scroll text-sm text-ellipsis whitespace-nowrap">
{invitationLink}
</p>
<p className="min-w-0 truncate text-sm">{invitationLink}</p>
</Snippet>
</div>
</CardContent>

View File

@@ -77,9 +77,7 @@ export const ColumnsInvitation: ColumnDef<InvitationProps>[] = [
},
{
accessorKey: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
header: () => null,
id: "actions",
cell: ({ row }) => {
const roles = row.original.roles;

View File

@@ -1,227 +1,13 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Database } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { Muted } from "@/components/findings/muted";
import { DataTableRowDetails } from "@/components/findings/table";
import { DeltaIndicator } from "@/components/findings/table/delta-indicator";
import { InfoIcon } from "@/components/icons";
import {
DateWithTime,
EntityInfo,
SnippetChip,
} from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import {
DataTableColumnHeader,
SeverityBadge,
StatusFindingBadge,
} from "@/components/ui/table";
import { FindingProps, ProviderType } from "@/types";
import { getColumnFindings } from "@/components/findings/table/column-findings";
import { FindingProps } from "@/types";
const getFindingsData = (row: { original: FindingProps }) => {
return row.original;
};
const baseColumns: ColumnDef<FindingProps>[] = getColumnFindings(
{} as RowSelectionState,
0,
).filter((column) => column.id !== "select" && column.id !== "actions");
const getFindingsMetadata = (row: { original: FindingProps }) => {
return row.original.attributes.check_metadata;
};
const getResourceData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["resource"]["attributes"],
) => {
return (
row.original.relationships?.resource?.attributes?.[field] ||
`No ${field} found in resource`
);
};
const getProviderData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["provider"]["attributes"],
) => {
return (
row.original.relationships?.provider?.attributes?.[field] ||
`No ${field} found in provider`
);
};
const FindingDetailsCell = ({ row }: { row: any }) => {
const searchParams = useSearchParams();
const findingId = searchParams.get("id");
const isOpen = findingId === row.original.id;
return (
<div className="flex justify-center">
<TriggerSheet
triggerComponent={
<InfoIcon className="text-button-primary" size={16} />
}
title="Finding Details"
description="View the finding details"
defaultOpen={isOpen}
>
<DataTableRowDetails
entityId={row.original.id}
findingDetails={row.original}
/>
</TriggerSheet>
</div>
);
};
export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
{
id: "moreInfo",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <FindingDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "check",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Finding" />
),
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
const {
attributes: { muted, muted_reason },
} = getFindingsData(row);
const { delta } = row.original.attributes;
return (
<div className="3xl:max-w-[660px] relative flex max-w-[410px] flex-row items-center gap-2">
<div className="flex flex-row items-center gap-4">
{delta === "new" || delta === "changed" ? (
<DeltaIndicator delta={delta} />
) : (
<div className="w-2" />
)}
<p className="mr-7 text-sm break-words whitespace-normal">
{checktitle}
</p>
</div>
<span className="absolute top-1/2 -right-2 -translate-y-1/2">
<Muted isMuted={muted} mutedReason={muted_reason || ""} />
</span>
</div>
);
},
enableSorting: false,
},
{
accessorKey: "resourceName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
return (
<SnippetChip
value={resourceName as string}
formatter={(value) => `...${value.slice(-10)}`}
icon={<Database size={16} />}
/>
);
},
enableSorting: false,
},
{
accessorKey: "severity",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Severity" />
),
cell: ({ row }) => {
const {
attributes: { severity },
} = getFindingsData(row);
return <SeverityBadge severity={severity} />;
},
enableSorting: false,
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const {
attributes: { status },
} = getFindingsData(row);
return <StatusFindingBadge size="sm" status={status} />;
},
enableSorting: false,
},
{
accessorKey: "updated_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Last seen" />
),
cell: ({ row }) => {
const {
attributes: { updated_at },
} = getFindingsData(row);
return (
<div className="w-[100px]">
<DateWithTime dateTime={updated_at} />
</div>
);
},
enableSorting: false,
},
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<div className="w-[80px]">
{typeof region === "string" ? region : "Invalid region"}
</div>
);
},
enableSorting: false,
},
{
accessorKey: "service",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Service" />
),
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="text-small max-w-96 truncate">{servicename}</p>;
},
enableSorting: false,
},
{
accessorKey: "cloudProvider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias as string}
entityId={uid as string}
/>
</>
);
},
enableSorting: false,
},
];
export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = baseColumns;

View File

@@ -105,9 +105,7 @@ export const ColumnsRoles: ColumnDef<RolesProps["data"][number]>[] = [
},
{
accessorKey: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
header: () => null,
id: "actions",
cell: ({ row }) => {
return <DataTableRowActions row={row} />;

View File

@@ -36,8 +36,8 @@ export const DateWithTime = ({
return (
<div
className={cn(
"flex gap-1",
inline ? "flex-row items-center" : "flex-col",
"gap-1",
inline ? "inline-flex flex-row items-center" : "flex flex-col",
)}
>
<span className="text-text-neutral-primary text-sm whitespace-nowrap">

View File

@@ -9,7 +9,6 @@ import {
ArrowUpIcon,
ChevronsLeftRightIcon,
} from "@/components/icons";
import { Button } from "@/components/shadcn";
interface DataTableColumnHeaderProps<TData, TValue>
extends HTMLAttributes<HTMLDivElement> {
@@ -74,23 +73,25 @@ export const DataTableColumnHeader = <TData, TValue>({
);
};
const baseClassName =
"text-text-neutral-primary flex h-8 items-center text-left align-middle text-sm font-semibold whitespace-nowrap outline-none -ml-px";
if (!column.getCanSort()) {
return (
<div className="text-text-neutral-primary flex items-center justify-between px-0 text-left align-middle text-sm font-semibold whitespace-nowrap outline-none">
<div className={baseClassName}>
<span className="block break-normal whitespace-nowrap">{title}</span>
</div>
);
}
return (
<Button
variant="ghost"
size="sm"
className="text-text-neutral-primary hover:text-text-neutral-tertiary -ml-3 flex items-center justify-between px-0 text-left align-middle text-sm font-semibold whitespace-nowrap outline-none hover:bg-transparent"
<button
type="button"
className={`${baseClassName} hover:text-text-neutral-tertiary cursor-pointer`}
onClick={getToggleSortingHandler}
>
<span className="block break-normal whitespace-nowrap">{title}</span>
{renderSortIcon()}
</Button>
</button>
);
};

View File

@@ -85,7 +85,7 @@ const TableHead = forwardRef<
className={cn(
"bg-bg-neutral-tertiary border-border-neutral-tertiary text-text-neutral-secondary border-y backdrop-blur-[46px]",
"h-11 px-1.5 text-left align-middle text-xs font-medium whitespace-nowrap outline-none",
"first:rounded-l-full first:border-l first:pl-1",
"first:rounded-l-full first:border-l first:pl-3",
"last:rounded-r-full last:border-r last:pr-3",
"data-[hover=true]:text-foreground-400 data-[focus-visible=true]:outline-focus",
"data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-offset-2",

View File

@@ -9,8 +9,8 @@ import { UserDataWithRoles } from "@/types/users";
const TenantIdCopy = ({ id }: { id: string }) => {
return (
<div className="flex items-center gap-2 whitespace-nowrap md:flex-col md:items-start md:justify-start">
<SnippetChip value={id} />
<div className="flex max-w-full min-w-0 items-center gap-2 md:flex-col md:items-start md:justify-start">
<SnippetChip value={id} className="max-w-full" />
</div>
);
};
@@ -48,7 +48,7 @@ export const UserBasicInfoCard = ({
</InfoField>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-2 overflow-hidden">
<InfoField label="Organization ID" variant="transparent">
{tenantId ? (
<TenantIdCopy id={tenantId} />

View File

@@ -75,9 +75,7 @@ export const ColumnsUser: ColumnDef<UserProps>[] = [
{
accessorKey: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
header: () => null,
id: "actions",
cell: ({ row }) => {
const roles = row.original.roles;