mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
"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 { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
|
import { JiraIcon } from "@/components/icons/services/IconServices";
|
|
import { Checkbox } from "@/components/shadcn";
|
|
import {
|
|
ActionDropdown,
|
|
ActionDropdownItem,
|
|
} from "@/components/shadcn/dropdown";
|
|
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
|
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
|
import { DateWithTime } from "@/components/ui/entities";
|
|
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
|
import { SeverityBadge } from "@/components/ui/table";
|
|
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
|
|
import {
|
|
type FindingStatus,
|
|
StatusFindingBadge,
|
|
} from "@/components/ui/table/status-finding-badge";
|
|
import { getFailingForLabel } from "@/lib/date-utils";
|
|
import { FindingResourceRow } from "@/types";
|
|
import type {
|
|
FindingTriageLoadedNote,
|
|
FindingTriageSummary,
|
|
} from "@/types/findings-triage";
|
|
|
|
import { canMuteFindingResource } from "./finding-resource-selection";
|
|
import {
|
|
FindingNoteActionItem,
|
|
FindingTriageStatusCell,
|
|
} from "./finding-triage-cells";
|
|
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
|
import { FindingsSelectionContext } from "./findings-selection-context";
|
|
import {
|
|
type DeltaType,
|
|
NotificationIndicator,
|
|
} from "./notification-indicator";
|
|
|
|
const ResourceRowActions = ({
|
|
row,
|
|
findingTitle,
|
|
onTriageUpdateAction,
|
|
onTriageNoteLoadAction,
|
|
}: {
|
|
row: Row<FindingResourceRow>;
|
|
findingTitle?: string;
|
|
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
|
onTriageNoteLoadAction?: (
|
|
triage: FindingTriageSummary,
|
|
) => Promise<FindingTriageLoadedNote>;
|
|
}) => {
|
|
const resource = row.original;
|
|
const canMute = canMuteFindingResource(resource);
|
|
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
|
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
|
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
|
const [isResolving, setIsResolving] = useState(false);
|
|
|
|
const { selectedFindingIds, clearSelection, resolveMuteIds, onMuteComplete } =
|
|
useContext(FindingsSelectionContext) || {
|
|
selectedFindingIds: [],
|
|
clearSelection: () => {},
|
|
};
|
|
|
|
const isCurrentSelected = selectedFindingIds.includes(resource.findingId);
|
|
const hasMultipleSelected = selectedFindingIds.length > 1;
|
|
|
|
const getDisplayIds = (): string[] => {
|
|
if (isCurrentSelected && hasMultipleSelected) {
|
|
return selectedFindingIds;
|
|
}
|
|
return [resource.findingId];
|
|
};
|
|
|
|
const getMuteLabel = () => {
|
|
if (resource.isMuted) return "Muted";
|
|
const ids = getDisplayIds();
|
|
if (ids.length > 1) return `Mute ${ids.length}`;
|
|
return "Mute";
|
|
};
|
|
|
|
const handleMuteClick = async () => {
|
|
const displayIds = getDisplayIds();
|
|
|
|
// Single resource: findingId is already a real finding UUID
|
|
if (displayIds.length === 1) {
|
|
setResolvedIds(displayIds);
|
|
setIsMuteModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
// Multi-select: resolve through context
|
|
if (resolveMuteIds) {
|
|
setIsResolving(true);
|
|
const ids = await resolveMuteIds(displayIds);
|
|
setResolvedIds(ids);
|
|
setIsResolving(false);
|
|
if (ids.length > 0) setIsMuteModalOpen(true);
|
|
} else {
|
|
setResolvedIds(displayIds);
|
|
setIsMuteModalOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleMuteComplete = () => {
|
|
clearSelection();
|
|
setResolvedIds([]);
|
|
onMuteComplete?.();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{canMute && (
|
|
<MuteFindingsModal
|
|
isOpen={isMuteModalOpen}
|
|
onOpenChange={setIsMuteModalOpen}
|
|
findingIds={resolvedIds}
|
|
onComplete={handleMuteComplete}
|
|
/>
|
|
)}
|
|
<SendToJiraModal
|
|
isOpen={isJiraModalOpen}
|
|
onOpenChange={setIsJiraModalOpen}
|
|
findingId={resource.findingId}
|
|
findingTitle={resource.checkId}
|
|
/>
|
|
<div
|
|
className="flex items-center justify-end"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ActionDropdown ariaLabel="Resource actions">
|
|
<FindingNoteActionItem
|
|
triage={resource.triage}
|
|
findingContext={{
|
|
title: findingTitle || resource.checkId,
|
|
resource: resource.resourceName,
|
|
provider: resource.providerAlias,
|
|
providerType: resource.providerType,
|
|
}}
|
|
onTriageUpdateAction={onTriageUpdateAction}
|
|
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
|
/>
|
|
<ActionDropdownItem
|
|
icon={
|
|
resource.isMuted ? (
|
|
<VolumeOff className="size-5" />
|
|
) : isResolving ? (
|
|
<Spinner className="size-5" />
|
|
) : (
|
|
<VolumeX className="size-5" />
|
|
)
|
|
}
|
|
label={isResolving ? "Resolving..." : getMuteLabel()}
|
|
disabled={!canMute || isResolving}
|
|
onSelect={handleMuteClick}
|
|
/>
|
|
<ActionDropdownItem
|
|
icon={<JiraIcon size={20} />}
|
|
label="Send to Jira"
|
|
onSelect={() => setIsJiraModalOpen(true)}
|
|
/>
|
|
</ActionDropdown>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface GetColumnFindingResourcesOptions {
|
|
rowSelection: RowSelectionState;
|
|
selectableRowCount: number;
|
|
findingTitle?: string;
|
|
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
|
onTriageNoteLoadAction?: (
|
|
triage: FindingTriageSummary,
|
|
) => Promise<FindingTriageLoadedNote>;
|
|
}
|
|
|
|
export function getColumnFindingResources({
|
|
rowSelection,
|
|
selectableRowCount,
|
|
findingTitle,
|
|
onTriageUpdateAction,
|
|
onTriageNoteLoadAction,
|
|
}: 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
|
|
delta={row.original.delta as DeltaType | undefined}
|
|
isMuted={row.original.isMuted}
|
|
mutedReason={row.original.mutedReason}
|
|
showDeltaWhenMuted
|
|
/>
|
|
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
|
|
<Checkbox
|
|
size="sm"
|
|
checked={!!rowSelection[row.id]}
|
|
disabled={!canMuteFindingResource(row.original)}
|
|
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Select resource"
|
|
/>
|
|
</div>
|
|
),
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
},
|
|
// Status
|
|
{
|
|
id: "status",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Status" />
|
|
),
|
|
cell: ({ row }) => {
|
|
return (
|
|
<StatusFindingBadge status={row.original.status as FindingStatus} />
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
// Resource — name + uid
|
|
{
|
|
id: "resource",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Resource" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="max-w-[240px]">
|
|
<EntityInfo
|
|
entityAlias={row.original.resourceName}
|
|
entityId={row.original.resourceUid}
|
|
/>
|
|
</div>
|
|
),
|
|
enableSorting: false,
|
|
},
|
|
// Provider — alias + uid (same style as Resource)
|
|
{
|
|
id: "provider",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Provider" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="max-w-[240px]">
|
|
<EntityInfo
|
|
entityAlias={row.original.providerAlias}
|
|
entityId={row.original.providerUid}
|
|
/>
|
|
</div>
|
|
),
|
|
enableSorting: false,
|
|
},
|
|
// Severity
|
|
{
|
|
id: "severity",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Severity" />
|
|
),
|
|
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
|
|
enableSorting: false,
|
|
},
|
|
// Service
|
|
{
|
|
id: "service",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Service" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<InfoField label="Service" variant="compact">
|
|
{row.original.service || "-"}
|
|
</InfoField>
|
|
),
|
|
enableSorting: false,
|
|
},
|
|
// Region
|
|
{
|
|
id: "region",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Region" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<InfoField label="Region" variant="compact">
|
|
<span className="block truncate whitespace-nowrap">
|
|
{row.original.region || "-"}
|
|
</span>
|
|
</InfoField>
|
|
),
|
|
enableSorting: false,
|
|
},
|
|
// Last seen
|
|
{
|
|
id: "lastSeen",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Last seen" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<InfoField label="Last seen" variant="compact">
|
|
<DateWithTime dateTime={row.original.lastSeenAt} />
|
|
</InfoField>
|
|
),
|
|
enableSorting: false,
|
|
},
|
|
// Failing for — duration since first_seen_at
|
|
{
|
|
id: "failingFor",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Failing for" />
|
|
),
|
|
cell: ({ row }) => {
|
|
const duration = getFailingForLabel(row.original.firstSeenAt);
|
|
return (
|
|
<InfoField label="Failing for" variant="compact">
|
|
{duration || "-"}
|
|
</InfoField>
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
// Triage — keep the compact label: these cells also render inside
|
|
// expanded finding-group rows, which have no header row of their own.
|
|
{
|
|
id: "triage",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Triage" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<InfoField label="Triage" variant="compact">
|
|
<FindingTriageStatusCell
|
|
triage={row.original.triage}
|
|
onTriageUpdateAction={onTriageUpdateAction}
|
|
/>
|
|
</InfoField>
|
|
),
|
|
enableSorting: false,
|
|
},
|
|
// Actions column — utility actions are kept last.
|
|
{
|
|
id: "actions",
|
|
size: 56,
|
|
header: () => <div className="w-10" />,
|
|
cell: ({ row }) => (
|
|
<ResourceRowActions
|
|
row={row}
|
|
findingTitle={findingTitle}
|
|
onTriageUpdateAction={onTriageUpdateAction}
|
|
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
|
/>
|
|
),
|
|
enableSorting: false,
|
|
},
|
|
];
|
|
}
|