Compare commits

...

8 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
13 changed files with 301 additions and 85 deletions

View File

@@ -3,6 +3,66 @@
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,

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

@@ -64,8 +64,6 @@ export function getColumnFindingGroups({
},
cell: ({ row }) => {
const group = row.original;
const allMuted =
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
const delta =
group.newCount > 0
@@ -76,23 +74,18 @@ export function getColumnFindingGroups({
return (
<div className="flex items-center gap-2">
<NotificationIndicator delta={delta} isMuted={allMuted} />
{group.resourcesTotal > 1 ? (
<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>
) : (
<div className="w-4" />
)}
<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]}
disabled={allMuted}
onCheckedChange={(checked) =>
row.toggleSelected(checked === true)
}
@@ -128,18 +121,10 @@ export function getColumnFindingGroups({
const group = row.original;
return (
<div className="max-w-[500px]">
<div>
<p
className={cn(
"text-text-neutral-primary text-left text-sm break-words whitespace-normal",
group.resourcesTotal > 1 &&
"hover:text-button-tertiary cursor-pointer hover:underline",
)}
onClick={
group.resourcesTotal > 1
? () => onDrillDown(group.checkId, group)
: undefined
}
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>

View File

@@ -48,8 +48,10 @@ function getFailingForLabel(firstSeenAt: string | null): string | null {
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 } = useContext(
const { selectedFindingIds, clearSelection, resolveMuteIds } = useContext(
FindingsSelectionContext,
) || {
selectedFindingIds: [],
@@ -59,7 +61,7 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const isCurrentSelected = selectedFindingIds.includes(resource.findingId);
const hasMultipleSelected = selectedFindingIds.length > 1;
const getMuteIds = (): string[] => {
const getDisplayIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
return selectedFindingIds;
}
@@ -68,20 +70,43 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const getMuteLabel = () => {
if (resource.isMuted) return "Muted";
const ids = getMuteIds();
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={getMuteIds()}
onComplete={clearSelection}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
/>
<div className="flex items-center justify-end">
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<ActionDropdown
trigger={
<button
@@ -101,13 +126,15 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
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={getMuteLabel()}
disabled={resource.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={resource.isMuted || isResolving}
onSelect={handleMuteClick}
/>
</ActionDropdown>
</div>

View File

@@ -68,41 +68,57 @@ export function DataTableRowActions<T extends FindingRowData>({
// 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 [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;
}
@@ -121,7 +137,7 @@ export function DataTableRowActions<T extends FindingRowData>({
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={getMuteIds()}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
/>
@@ -145,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

@@ -7,10 +7,12 @@ import {
RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { ChevronLeft } from "lucide-react";
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,
@@ -25,7 +27,7 @@ import { useInfiniteResources } from "@/hooks/use-infinite-resources";
import { cn, hasDateOrScanFilter } from "@/lib";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { MuteFindingsModal } from "../mute-findings-modal";
import { getColumnFindingResources } from "./column-finding-resources";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
@@ -108,12 +110,42 @@ export function FindingsGroupDrillDown({
router.refresh();
};
// Selection logic for resources
// 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 => {
@@ -130,6 +162,7 @@ export function FindingsGroupDrillDown({
const handleMuteComplete = () => {
clearSelection();
setResolvedFindingIds([]);
router.refresh();
};
@@ -170,6 +203,7 @@ export function FindingsGroupDrillDown({
selectedFindings: [],
clearSelection,
isSelected,
resolveMuteIds: resolveResourceIds,
}}
>
<div
@@ -282,11 +316,29 @@ export function FindingsGroupDrillDown({
</div>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onComplete={handleMuteComplete}
/>
<>
<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

View File

@@ -1,13 +1,17 @@
"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { VolumeX } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
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 { FloatingMuteButton } from "../floating-mute-button";
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";
@@ -79,8 +83,32 @@ export function FindingsGroupTable({
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();
};
@@ -124,6 +152,7 @@ export function FindingsGroupTable({
selectedFindings,
clearSelection,
isSelected,
resolveMuteIds,
}}
>
<DataTable
@@ -140,11 +169,29 @@ export function FindingsGroupTable({
/>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onComplete={handleMuteComplete}
/>
<>
<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

@@ -4,10 +4,11 @@ import { createContext, useContext } from "react";
interface FindingsSelectionContextValue {
selectedFindingIds: string[];
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

@@ -608,7 +608,7 @@ export function ResourceDetailDrawerContent({
{/* Lighthouse AI button */}
<a
href="/lighthouse"
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%)",

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

@@ -4,8 +4,8 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useFilterTransitionOptional } from "@/contexts";
// const FINDINGS_PATH = "/findings";
// const DEFAULT_MUTED_FILTER = "false";
const FINDINGS_PATH = "/findings";
const DEFAULT_MUTED_FILTER = "false";
/**
* Custom hook to handle URL filters and automatically reset
@@ -22,12 +22,10 @@ export const useUrlFilters = () => {
const isPending = false;
const ensureFindingsDefaultMuted = (params: URLSearchParams) => {
// TODO: Re-enable when finding-groups endpoint supports filter[muted]
// Findings defaults to excluding muted findings unless user sets it explicitly.
// if (pathname === FINDINGS_PATH && !params.has("filter[muted]")) {
// params.set("filter[muted]", DEFAULT_MUTED_FILTER);
// }
void params;
if (pathname === FINDINGS_PATH && !params.has("filter[muted]")) {
params.set("filter[muted]", DEFAULT_MUTED_FILTER);
}
};
const navigate = (params: URLSearchParams) => {

View File

@@ -83,7 +83,7 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
groupLabel: "",
menus: [
{
href: "/findings",
href: "/findings?filter[muted]=false",
label: "Findings",
icon: Tag,
},