Files
prowler/ui/components/findings/table/column-finding-resources.tsx
T
2026-07-02 12:43:06 +02:00

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