diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx index 23bc24b4b9..aeb12998fd 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx @@ -105,6 +105,15 @@ describe("MessageBubble", () => { label: "Findings", path: "/findings", }, + { + kind: "finding", + id: "finding-1", + source: "focused", + scope_key: "findings:/findings", + label: "Focused finding", + finding_id: "finding-1", + check_id: "aws_s3_bucket_public_access", + }, ], }, }, @@ -119,7 +128,7 @@ describe("MessageBubble", () => { render(); // Then - expect(screen.getByText("@ Findings")).toBeInTheDocument(); + expect(screen.getByText("@ Findings +1")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Remove Findings context/ }), ).not.toBeInTheDocument(); diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx index d75a164061..e46daa6516 100644 --- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx @@ -11,6 +11,11 @@ import type { LighthouseV2Session, LighthouseV2SupportedModel, } from "@/app/(prowler)/lighthouse/_types"; +import { + buildAttackPathContext, + buildFocusedFindingContext, +} from "@/lib/lighthouse/context/contributions"; +import { useLighthouseContextStore } from "@/store/lighthouse-context/store"; import { LighthousePanelChat, @@ -101,6 +106,7 @@ describe("LighthousePanelChat", () => { stubEventSource(); resetPanelChatStoreForTests(); resetPanelChatConfigCacheForTests(); + useLighthouseContextStore.getState().resetContributions(); getConfigurationsMock.mockResolvedValue({ data: configurations }); getSupportedProvidersMock.mockResolvedValue({ @@ -243,6 +249,82 @@ describe("LighthousePanelChat", () => { ); }); + it("sends page, focused finding, and parent Attack Path context together", async () => { + // Given + const user = userEvent.setup(); + window.history.replaceState(null, "", "/attack-paths?scanId=scan-1"); + const contextStore = useLighthouseContextStore.getState(); + contextStore.registerContribution( + "attack-path-current", + buildAttackPathContext({ + pathname: "/attack-paths", + scanId: "scan-1", + queryId: "query-1", + queryLabel: "Internet-exposed resources", + }), + ); + contextStore.setFocusedContext( + 1, + buildFocusedFindingContext({ + pathname: "/attack-paths", + findingId: "finding-1", + checkId: "aws_s3_bucket_public_access", + severity: "critical", + status: "FAIL", + providerUid: "123456789012", + resourceUid: "arn:aws:s3:::example", + region: "eu-west-1", + }), + ); + createSessionMock.mockResolvedValue({ + data: session("session-context", "Explain this finding"), + }); + sendMessageMock.mockResolvedValue({ + data: { + task: { + id: "task-context", + name: "lighthouse-run", + state: "executing", + }, + }, + }); + render(); + const input = await screen.findByRole("textbox", { name: "Message" }); + expect(screen.getByText("@ Attack Paths +1")).toBeInTheDocument(); + + // When + await user.type(input, "Explain this finding{Enter}"); + + // Then + await waitFor(() => + expect(sendMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + displayText: "Explain this finding", + context: expect.objectContaining({ + items: [ + expect.objectContaining({ + kind: "page", + id: "attack-paths", + filters: { scanId: ["scan-1"] }, + }), + expect.objectContaining({ + kind: "finding", + id: "finding-1", + source: "focused", + }), + expect.objectContaining({ + kind: "attack_path", + id: "current-query", + scanId: "scan-1", + queryId: "query-1", + }), + ], + }), + }), + ), + ); + }); + it("opens a recent chat in place without navigating", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts index 024e57f584..8b4512ebb4 100644 --- a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts +++ b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts @@ -162,9 +162,9 @@ describe("createLighthouseChatStore", () => { it("retries with the original context snapshot", async () => { // Given const store = makeStore(); - const context = findingsContext(); + const context = focusedFindingsContext(); await store.getState().submitMessage("Prioritize findings", context); - context.items[0].label = "Mutated after send"; + context.items[1].label = "Mutated after send"; eventSources[0].fail(2 /* EventSource.CLOSED */); sendMessageMock.mockResolvedValueOnce({ data: { @@ -179,7 +179,7 @@ describe("createLighthouseChatStore", () => { expect(sendMessageMock).toHaveBeenNthCalledWith(2, { sessionId: "session-1", displayText: "Prioritize findings", - context: findingsContext(), + context: focusedFindingsContext(), provider: "openai", model: "gpt-5.1", }); @@ -596,6 +596,25 @@ function findingsContext(): LighthouseContextEnvelope { }; } +function focusedFindingsContext(): LighthouseContextEnvelope { + const context = findingsContext(); + return { + ...context, + items: [ + ...context.items, + { + kind: "finding", + id: "finding-1", + source: "focused", + scopeKey: "findings:/findings", + label: "Focused finding", + findingId: "finding-1", + checkId: "aws_s3_bucket_public_access", + }, + ], + }; +} + function oversizedFindingsContext(): LighthouseContextEnvelope { const context = findingsContext(); return { diff --git a/ui/changelog.d/lighthouse-contextual-pages.added.md b/ui/changelog.d/lighthouse-contextual-pages.added.md index 9784ad6d9e..cec94a52c3 100644 --- a/ui/changelog.d/lighthouse-contextual-pages.added.md +++ b/ui/changelog.d/lighthouse-contextual-pages.added.md @@ -1 +1 @@ -Lighthouse AI contextual messages with page-aware prompts, removable context chips, selected-resource metadata, and retry-safe historical badges +Lighthouse AI contextual messages with page-aware prompts, focused side-panel details, selected-resource metadata, and retry-safe historical badges diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx new file mode 100644 index 0000000000..6e9356e229 --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx @@ -0,0 +1,130 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { FindingResourceRow } from "@/types"; + +import { ResourceDetailDrawer } from "./resource-detail-drawer"; + +const { pathnameMock } = vi.hoisted(() => ({ + pathnameMock: vi.fn(() => "/findings"), +})); + +vi.mock("next/navigation", () => ({ + usePathname: pathnameMock, +})); + +vi.mock("@/components/side-panel/detail-side-panel", () => ({ + DetailSidePanel: ({ + context, + children, + }: { + context?: unknown; + children: ReactNode; + }) => ( + <> + {JSON.stringify(context)} + {children} + + ), +})); + +vi.mock("./resource-detail-drawer-content", () => ({ + ResourceDetailDrawerContent: () =>
Finding details
, +})); + +describe("ResourceDetailDrawer", () => { + beforeEach(() => { + pathnameMock.mockReturnValue("/findings"); + }); + + it("should update focused finding context when drawer navigation changes", () => { + // Given + const firstFinding = findingResource("finding-1", "bucket-1"); + const secondFinding = findingResource("finding-2", "bucket-2"); + const { rerender } = renderDrawer(firstFinding); + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"findingId":"finding-1"', + ); + + // When + rerender(drawer(secondFinding)); + + // Then + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"findingId":"finding-2"', + ); + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"resourceUid":"bucket-2"', + ); + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"source":"focused"', + ); + }); + + it("should scope a finding opened from an Attack Paths node", () => { + // Given + pathnameMock.mockReturnValue("/attack-paths"); + + // When + renderDrawer(findingResource("finding-attack-path", "bucket-attack-path")); + + // Then + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"scopeKey":"attack-paths:/attack-paths"', + ); + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"findingId":"finding-attack-path"', + ); + }); +}); + +function renderDrawer(currentResource: FindingResourceRow) { + return render(drawer(currentResource)); +} + +function drawer(currentResource: FindingResourceRow) { + return ( + + ); +} + +function findingResource( + findingId: string, + resourceUid: string, +): FindingResourceRow { + return { + id: findingId, + rowType: "resource", + findingId, + checkId: "aws_s3_bucket_public_access", + providerType: "aws", + providerAlias: "Production", + providerUid: "123456789012", + resourceName: resourceUid, + resourceType: "AwsS3Bucket", + resourceGroup: "storage", + resourceUid, + service: "s3", + region: "eu-west-1", + severity: "critical", + status: "FAIL", + isMuted: false, + firstSeenAt: null, + lastSeenAt: null, + }; +} diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx index c2c4629d63..adb132bbfe 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx @@ -1,7 +1,10 @@ "use client"; +import { usePathname } from "next/navigation"; + import type { ResourceDrawerFinding } from "@/actions/findings"; import { DetailSidePanel } from "@/components/side-panel/detail-side-panel"; +import { buildFocusedFindingContext } from "@/lib/lighthouse/context/contributions"; import type { FindingResourceRow } from "@/types"; import type { UpdateFindingTriageInput } from "@/types/findings-triage"; @@ -43,12 +46,27 @@ export function ResourceDetailDrawer({ onMuteComplete, onTriageUpdate, }: ResourceDetailDrawerProps) { + const pathname = usePathname(); + const context = currentResource + ? buildFocusedFindingContext({ + pathname, + findingId: currentFinding?.id ?? currentResource.findingId, + checkId: currentFinding?.checkId ?? currentResource.checkId, + severity: currentFinding?.severity ?? currentResource.severity, + status: currentFinding?.status ?? currentResource.status, + providerUid: currentFinding?.providerUid ?? currentResource.providerUid, + resourceUid: currentFinding?.resourceUid ?? currentResource.resourceUid, + region: currentFinding?.resourceRegion ?? currentResource.region, + }) + : undefined; + return ( { await user.hover(contextBadge); // Then - expect(contextBadge).toHaveTextContent("@ Findings +1"); expect( screen.queryByRole("button", { name: /Findings context/ }), ).not.toBeInTheDocument(); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Findings context will be included in your next message.", + const tooltip = await screen.findByRole("tooltip"); + expect(contextBadge).toHaveTextContent("@ Findings +2"); + expect(tooltip).toHaveTextContent("Filters: severity: critical"); + expect(tooltip).toHaveTextContent("Finding: finding-focused"); + expect(tooltip).toHaveTextContent("Finding: finding-1"); + }); + + it.each([ + ["resource", resourceContext(), "Resource: resource-1 (bucket-1)"], + ["scan", scanContext(), "Scan: scan-1"], + ["Attack Path", attackPathContext(), "Attack Path: query-1 (scan scan-1)"], + ])("should identify included %s context", async (_, context, expected) => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.hover( + screen.getByLabelText(`${context.items[0].label} context`), ); + + // Then + expect(await screen.findByRole("tooltip")).toHaveTextContent(expected); }); }); @@ -36,7 +55,7 @@ describe("LighthouseContextBadge", () => { render(); // Then - expect(screen.getByText("@ Findings +1")).toBeInTheDocument(); + expect(screen.getByText("@ Findings +2")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Findings context/ }), ).not.toBeInTheDocument(); @@ -65,6 +84,94 @@ function findingsContext(): LighthouseContextEnvelope { label: "Selected finding", findingId: "finding-1", }, + { + kind: "finding", + id: "finding-focused", + source: "focused", + scopeKey: "findings:/findings", + label: "Focused finding", + findingId: "finding-focused", + checkId: "aws_s3_bucket_public_access", + }, + ], + }; +} + +function resourceContext(): LighthouseContextEnvelope { + return { + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "page", + id: "resources", + source: "automatic", + scopeKey: "resources:/resources", + label: "Resources", + path: "/resources", + }, + { + kind: "resource", + id: "resource-1", + source: "focused", + scopeKey: "resources:/resources", + label: "Focused resource", + resourceId: "resource-1", + resourceUid: "bucket-1", + }, + ], + }; +} + +function scanContext(): LighthouseContextEnvelope { + return { + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "page", + id: "scans", + source: "automatic", + scopeKey: "scans:/scans", + label: "Scans", + path: "/scans", + filters: { scanId: ["scan-1"] }, + }, + { + kind: "scan", + id: "scan-1", + source: "selection", + scopeKey: "scans:/scans", + label: "Selected scan", + scanId: "scan-1", + }, + ], + }; +} + +function attackPathContext(): LighthouseContextEnvelope { + return { + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "page", + id: "attack-paths", + source: "automatic", + scopeKey: "attack-paths:/attack-paths", + label: "Attack Paths", + path: "/attack-paths", + filters: { scanId: ["scan-1"] }, + }, + { + kind: "attack_path", + id: "current-query", + source: "automatic", + scopeKey: "attack-paths:/attack-paths", + label: "Internet-exposed resources", + scanId: "scan-1", + queryId: "query-1", + }, ], }; } diff --git a/ui/components/lighthouse/context-chip.tsx b/ui/components/lighthouse/context-chip.tsx index 0909138dc8..f3a9518c04 100644 --- a/ui/components/lighthouse/context-chip.tsx +++ b/ui/components/lighthouse/context-chip.tsx @@ -10,6 +10,7 @@ import { LIGHTHOUSE_CONTEXT_KIND, LIGHTHOUSE_CONTEXT_SOURCE, type LighthouseContextEnvelope, + type LighthouseContextItem, } from "@/types/lighthouse-context"; interface LighthouseCurrentContextBadgeProps { @@ -20,20 +21,18 @@ export function LighthouseCurrentContextBadge({ context, }: LighthouseCurrentContextBadgeProps) { if (!context) return null; - const { pageLabel, selectionCount } = getContextBadgeContent(context); + const { pageLabel, additionalCount } = getContextBadgeContent(context); return ( - {buildContextLabel(pageLabel, selectionCount)} + {buildContextLabel(pageLabel, additionalCount)} - - {pageLabel} context will be included in your next message. - + ); } @@ -43,14 +42,14 @@ export function LighthouseContextBadge({ }: { context: LighthouseContextEnvelope; }) { - const { pageLabel, selectionCount } = getContextBadgeContent(context); + const { pageLabel, additionalCount } = getContextBadgeContent(context); return ( - {buildContextLabel(pageLabel, selectionCount)} + {buildContextLabel(pageLabel, additionalCount)} @@ -64,11 +63,13 @@ function getContextBadgeContent(context: LighthouseContextEnvelope) { (item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE, ); const pageLabel = page?.label ?? "Context"; - const selectionCount = context.items.filter( - (item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, + const additionalCount = context.items.filter( + (item) => + item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED || + item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, ).length; - return { pageLabel, selectionCount }; + return { pageLabel, additionalCount }; } function LighthouseContextTooltip({ @@ -85,29 +86,79 @@ function LighthouseContextTooltip({ .map(([key, values]) => `${key}: ${values.join(", ")}`) .join("; ") : ""; - const summaries = context.items - .filter( - (item) => - item.kind !== LIGHTHOUSE_CONTEXT_KIND.PAGE && - item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC, - ) - .map((item) => item.label) - .join(", "); - const types = Array.from( - new Set(context.items.map((item) => item.kind)), - ).join(", "); + const itemDescriptions = context.items + .map(getContextItemDescription) + .filter((description) => description !== null); return (
+ {page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE && ( +

Page: {page.path}

+ )} {filters &&

Filters: {filters}

} - {summaries &&

Summaries: {summaries}

} -

Included types: {types}

+ {itemDescriptions.map(({ id, text }) => ( +

{text}

+ ))}
); } -function buildContextLabel(pageLabel: string, selectionCount: number): string { - return `@ ${pageLabel}${selectionCount > 0 ? ` +${selectionCount}` : ""}`; +interface ContextItemDescription { + id: string; + text: string; +} + +function getContextItemDescription( + item: LighthouseContextItem, +): ContextItemDescription | null { + if (item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE) return null; + if ( + item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC && + item.id === "summary" + ) { + return { id: `${item.kind}:${item.id}`, text: `Summary: ${item.label}` }; + } + + switch (item.kind) { + case LIGHTHOUSE_CONTEXT_KIND.FINDING: + return { + id: `${item.kind}:${item.id}`, + text: `Finding: ${item.findingId}${item.checkId ? ` (${item.checkId})` : ""}`, + }; + case LIGHTHOUSE_CONTEXT_KIND.RESOURCE: + return { + id: `${item.kind}:${item.id}`, + text: `Resource: ${item.resourceId}${item.resourceUid ? ` (${item.resourceUid})` : ""}`, + }; + case LIGHTHOUSE_CONTEXT_KIND.COMPLIANCE: + return { + id: `${item.kind}:${item.id}`, + text: `Compliance: ${item.framework}${item.scanId ? ` (scan ${item.scanId})` : ""}`, + }; + case LIGHTHOUSE_CONTEXT_KIND.ATTACK_PATH: + return { + id: `${item.kind}:${item.id}`, + text: `Attack Path: ${item.queryId ?? item.id}${item.scanId ? ` (scan ${item.scanId})` : ""}`, + }; + case LIGHTHOUSE_CONTEXT_KIND.SCAN: + return { + id: `${item.kind}:${item.id}`, + text: `Scan: ${item.scanId ?? item.id}`, + }; + case LIGHTHOUSE_CONTEXT_KIND.PROVIDER: + return { + id: `${item.kind}:${item.id}`, + text: `Provider: ${item.providerUid ?? item.providerId ?? item.id}`, + }; + default: { + const exhaustiveItem: never = item; + return exhaustiveItem; + } + } +} + +function buildContextLabel(pageLabel: string, additionalCount: number): string { + return `@ ${pageLabel}${additionalCount > 0 ? ` +${additionalCount}` : ""}`; } diff --git a/ui/components/resources/resource-details-sheet.tsx b/ui/components/resources/resource-details-sheet.tsx index c0603d9124..3fe5006723 100644 --- a/ui/components/resources/resource-details-sheet.tsx +++ b/ui/components/resources/resource-details-sheet.tsx @@ -1,7 +1,10 @@ "use client"; +import { usePathname } from "next/navigation"; + import { DetailSidePanel } from "@/components/side-panel/detail-side-panel"; -import { ResourceProps } from "@/types"; +import { buildFocusedResourceContext } from "@/lib/lighthouse/context/contributions"; +import type { ResourceProps } from "@/types"; import { ResourceDetailContent } from "./table/resource-detail-content"; @@ -16,12 +19,21 @@ export const ResourceDetailsSheet = ({ open, onOpenChange, }: ResourceDetailsSheetProps) => { + const pathname = usePathname(); + const context = buildFocusedResourceContext({ + pathname, + id: resource.id, + attributes: resource.attributes, + providerUid: resource.relationships.provider.data.attributes.uid, + }); + return ( diff --git a/ui/components/resources/table/resources-table-with-selection.test.tsx b/ui/components/resources/table/resources-table-with-selection.test.tsx index b4b9d36d39..09d2c63326 100644 --- a/ui/components/resources/table/resources-table-with-selection.test.tsx +++ b/ui/components/resources/table/resources-table-with-selection.test.tsx @@ -1,4 +1,6 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { ResourceProps } from "@/types"; @@ -19,8 +21,27 @@ vi.mock("@/components/shadcn/table", () => ({ ), })); -vi.mock("@/components/resources/resource-details-sheet", () => ({ - ResourceDetailsSheet: () =>
Resource details
, +vi.mock("next/navigation", () => ({ + usePathname: () => "/resources", +})); + +vi.mock("@/components/side-panel/detail-side-panel", () => ({ + DetailSidePanel: ({ + context, + children, + }: { + context?: unknown; + children: ReactNode; + }) => ( + <> + {JSON.stringify(context)} + {children} + + ), +})); + +vi.mock("./resource-detail-content", () => ({ + ResourceDetailContent: () =>
Resource details
, })); vi.mock("@/components/lighthouse/context-contributor", () => ({ @@ -38,21 +59,54 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({ })); const resource = { + type: "resources", id: "resource-1", attributes: { + inserted_at: "2026-07-22T10:00:00Z", + updated_at: "2026-07-22T10:00:00Z", uid: "arn:aws:s3:::example", + name: "example", service: "s3", region: "eu-west-1", type: "AwsS3Bucket", + groups: ["storage"], failed_findings_count: 3, + details: "full configuration must stay local", + partition: "aws", + tags: { owner: "security@example.com" }, + metadata: { secret: "do-not-send" }, }, relationships: { - provider: { data: { attributes: { uid: "123456789012" } } }, + provider: { + data: { + type: "providers", + id: "provider-1", + attributes: { + inserted_at: "2026-07-22T10:00:00Z", + updated_at: "2026-07-22T10:00:00Z", + provider: "aws", + uid: "123456789012", + alias: "Production", + connection: { + connected: true, + last_checked_at: "2026-07-22T10:00:00Z", + }, + }, + relationships: { + secret: { data: { type: "provider-secrets", id: "secret-1" } }, + }, + links: { self: "/providers/provider-1" }, + }, + }, + findings: { meta: { count: 0 }, data: [] }, }, -} as ResourceProps; + links: { self: "/resources/resource-1" }, +} satisfies ResourceProps; describe("ResourcesTableWithSelection", () => { - it("publishes the loaded total and selected resource as context", async () => { + it("publishes the loaded total and opens the selected resource detail", async () => { + // Given + const user = userEvent.setup(); render( { '"total":17', ); - screen.getByRole("button", { name: "Open resource" }).click(); + // When + await user.click(screen.getByRole("button", { name: "Open resource" })); + // Then + expect(screen.getByText("Resource details")).toBeInTheDocument(); + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"resourceId":"resource-1"', + ); + expect(screen.getByTestId("focused-context")).toHaveTextContent( + '"providerUid":"123456789012"', + ); + expect(screen.getByTestId("focused-context")).not.toHaveTextContent( + "full configuration must stay local", + ); + expect(screen.getByTestId("focused-context")).not.toHaveTextContent( + "security@example.com", + ); + expect(screen.getByTestId("focused-context")).not.toHaveTextContent( + "do-not-send", + ); expect( - await screen.findByTestId("context-resource-resource-1"), - ).toHaveTextContent('"providerUid":"123456789012"'); + screen.queryByTestId("context-resource-resource-1"), + ).not.toBeInTheDocument(); }); }); diff --git a/ui/components/resources/table/resources-table-with-selection.tsx b/ui/components/resources/table/resources-table-with-selection.tsx index cff4f72e2e..473ea48442 100644 --- a/ui/components/resources/table/resources-table-with-selection.tsx +++ b/ui/components/resources/table/resources-table-with-selection.tsx @@ -5,10 +5,7 @@ import { useState } from "react"; import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor"; import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet"; import { DataTable } from "@/components/shadcn/table"; -import { - buildResourceContext, - buildResourceSummaryContext, -} from "@/lib/lighthouse/context/contributions"; +import { buildResourceSummaryContext } from "@/lib/lighthouse/context/contributions"; import { MetaDataProps, ResourceProps } from "@/types"; import { getColumnResources } from "./column-resources"; @@ -46,18 +43,6 @@ export function ResourcesTableWithSelection({ item={buildResourceSummaryContext(metadata.pagination.count)} /> )} - {selectedResource && ( - - )}
detail body
@@ -61,10 +70,34 @@ function DualHost() { Open B - +
A body
- +
B body
{String(openA)} @@ -73,6 +106,34 @@ function DualHost() { ); } +function NavigatingHost() { + const [findingId, setFindingId] = useState("finding-1"); + + return ( + <> + + + +
{findingId}
+
+ + ); +} + describe("DetailSidePanel", () => { beforeEach(() => { isCloudMock.mockReturnValue(true); @@ -85,6 +146,7 @@ describe("DetailSidePanel", () => { contextOwnerToken: 0, contextOutlet: null, }); + useLighthouseContextStore.getState().resetContributions(); }); it("portals the detail content into the global panel when open", async () => { @@ -131,6 +193,13 @@ describe("DetailSidePanel", () => { expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument(); expect(screen.getByTestId("detail-content")).toBeInTheDocument(); expect(screen.getByTestId("detail-content")).not.toBeVisible(); + expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-1"); + + // When + await user.click(screen.getByRole("button", { name: "Close side panel" })); + + // Then + expect(useLighthouseContextStore.getState().focused).toBeNull(); }); it("hands the panel to the newest detail view and closes the previous one", async () => { @@ -151,6 +220,7 @@ describe("DetailSidePanel", () => { expect(screen.queryByTestId("detail-a")).not.toBeInTheDocument(); expect(screen.getByTestId("open-a")).toHaveTextContent("false"); expect(screen.getByTestId("open-b")).toHaveTextContent("true"); + expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-b"); // When: the panel is dismissed await user.click(screen.getByRole("button", { name: "Close side panel" })); @@ -159,6 +229,7 @@ describe("DetailSidePanel", () => { expect(screen.getByTestId("open-b")).toHaveTextContent("false"); expect(screen.queryByTestId("detail-b")).not.toBeInTheDocument(); expect(useSidePanelStore.getState().contextTab).toBeNull(); + expect(useLighthouseContextStore.getState().focused).toBeNull(); }); it("registers nothing while closed and registers on open", async () => { @@ -174,4 +245,21 @@ describe("DetailSidePanel", () => { expect(await screen.findByTestId("detail-content")).toBeInTheDocument(); expect(useSidePanelStore.getState().contextTab?.label).toBe("Details"); }); + + it("updates focused context while navigating inside the current drawer", async () => { + // Given + const user = userEvent.setup(); + render(); + await screen.findByText("finding-1"); + expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-1"); + + // When + await user.click(screen.getByRole("button", { name: "Next finding" })); + + // Then + expect(screen.getByTestId("navigating-detail")).toHaveTextContent( + "finding-2", + ); + expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-2"); + }); }); diff --git a/ui/components/side-panel/detail-side-panel.tsx b/ui/components/side-panel/detail-side-panel.tsx index c7d30aaa4c..230fe42ea2 100644 --- a/ui/components/side-panel/detail-side-panel.tsx +++ b/ui/components/side-panel/detail-side-panel.tsx @@ -4,7 +4,9 @@ import { type ReactNode, useState } from "react"; import { createPortal } from "react-dom"; import { useMountEffect } from "@/hooks/use-mount-effect"; +import { useLighthouseContextStore } from "@/store/lighthouse-context/store"; import { useSidePanelStore } from "@/store/side-panel"; +import type { LighthouseContextItem } from "@/types/lighthouse-context"; interface DetailSidePanelProps { open: boolean; @@ -12,6 +14,7 @@ interface DetailSidePanelProps { // Screen-reader heading; the visible tab label is always "Details". title: string; description?: string; + context?: LighthouseContextItem; children: ReactNode; } @@ -34,6 +37,7 @@ function DetailSidePanelActive({ onOpenChange, title, description, + context, children, }: Omit) { // Owner token from registration: several detail views can be mounted at @@ -47,20 +51,60 @@ function DetailSidePanelActive({ // and every consumer's close path ends in stable setters. onRequestClose: () => onOpenChange(false), }); + useLighthouseContextStore + .getState() + .setFocusedContext(registered, context ?? null); setToken(registered); - return () => useSidePanelStore.getState().unregisterContextTab(registered); + return () => { + useLighthouseContextStore.getState().clearFocusedContext(registered); + useSidePanelStore.getState().unregisterContextTab(registered); + }; }); const ownerToken = useSidePanelStore((state) => state.contextOwnerToken); const outlet = useSidePanelStore((state) => state.contextOutlet); - if (!outlet || token === null || token !== ownerToken) return null; + const focusedRegistration = + context && token !== null ? ( + + ) : null; - return createPortal( -
-

{title}

- {description ?

{description}

: null} - {children} -
, - outlet, + if (!outlet || token === null || token !== ownerToken) { + return focusedRegistration; + } + + return ( + <> + {focusedRegistration} + {createPortal( +
+

{title}

+ {description ?

{description}

: null} + {children} +
, + outlet, + )} + ); } + +interface FocusedContextRegistrationProps { + ownerToken: number; + context: LighthouseContextItem; +} + +function FocusedContextRegistration({ + ownerToken, + context, +}: FocusedContextRegistrationProps) { + useMountEffect(() => { + useLighthouseContextStore.getState().setFocusedContext(ownerToken, context); + return () => + useLighthouseContextStore.getState().clearFocusedContext(ownerToken); + }); + + return null; +} diff --git a/ui/hooks/use-lighthouse-context.test.ts b/ui/hooks/use-lighthouse-context.test.ts index f07ffd57a0..a612416d56 100644 --- a/ui/hooks/use-lighthouse-context.test.ts +++ b/ui/hooks/use-lighthouse-context.test.ts @@ -40,4 +40,46 @@ describe("buildCurrentLighthouseContext", () => { expect(current.page.label).toBe("Findings"); expect(current.selectionCount).toBe(0); }); + + it("should combine the page, focused detail, and parent attack path", () => { + // Given + const parentContext = { + kind: "attack_path" as const, + id: "current-query", + source: "automatic" as const, + scopeKey: "attack-paths:/attack-paths", + label: "Internet-exposed resources", + scanId: "scan-1", + queryId: "query-1", + }; + const focusedContext = { + kind: "finding" as const, + id: "finding-1", + source: "focused" as const, + scopeKey: "attack-paths:/attack-paths", + label: "Focused finding", + findingId: "finding-1", + checkId: "aws_s3_bucket_public_access", + }; + + // When + const current = buildCurrentLighthouseContext( + "/attack-paths", + new URLSearchParams("scanId=scan-1"), + [parentContext], + focusedContext, + ); + + // Then + expect(current.context?.items.map((item) => item.id)).toEqual([ + "attack-paths", + "finding-1", + "current-query", + ]); + expect(current.context?.items[0]).toMatchObject({ + kind: "page", + filters: { scanId: ["scan-1"] }, + }); + expect(current.selectionCount).toBe(1); + }); }); diff --git a/ui/hooks/use-lighthouse-context.ts b/ui/hooks/use-lighthouse-context.ts index c4c0dd3042..337126ee45 100644 --- a/ui/hooks/use-lighthouse-context.ts +++ b/ui/hooks/use-lighthouse-context.ts @@ -29,11 +29,13 @@ export function useLighthouseCurrentContext(): LighthouseCurrentContext { const contributions = useLighthouseContextStore( (state) => state.contributions, ); + const focused = useLighthouseContextStore((state) => state.focused); return buildCurrentLighthouseContext( pathname, new URLSearchParams(searchParams.toString()), Object.values(contributions), + focused ?? undefined, ); } @@ -41,6 +43,7 @@ export function buildCurrentLighthouseContext( pathname: string, searchParams: URLSearchParams, contributions: LighthouseContextItem[], + focused?: LighthouseContextItem, ): LighthouseCurrentContext { const page = resolveLighthousePage(pathname); const scopeKey = getLighthouseScopeKey(pathname); @@ -49,7 +52,7 @@ export function buildCurrentLighthouseContext( (item) => item.scopeKey === scopeKey, ); const context = compileLighthouseContext( - [pageContext, ...scopedContributions], + [pageContext, ...(focused ? [focused] : []), ...scopedContributions], scopeKey, ); @@ -59,7 +62,9 @@ export function buildCurrentLighthouseContext( scopeKey, selectionCount: context?.items.filter( - (item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, + (item) => + item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED || + item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, ).length ?? 0, }; } diff --git a/ui/lib/lighthouse/context/contributions.test.ts b/ui/lib/lighthouse/context/contributions.test.ts index 68e9b30d24..0d407bde6f 100644 --- a/ui/lib/lighthouse/context/contributions.test.ts +++ b/ui/lib/lighthouse/context/contributions.test.ts @@ -6,6 +6,8 @@ import { buildFindingGroupContext, buildFindingResourceContext, buildFindingSummaryContext, + buildFocusedFindingContext, + buildFocusedResourceContext, buildProviderContext, buildProviderSummaryContext, buildResourceContext, @@ -69,6 +71,36 @@ describe("Lighthouse page contributions", () => { }); }); + it("builds a focused finding for the owning page scope", () => { + // Given / When + const context = buildFocusedFindingContext({ + pathname: "/attack-paths", + findingId: "finding-2", + checkId: "aws_s3_bucket_public_access", + severity: "critical", + status: "FAIL", + providerUid: "123456789012", + resourceUid: "arn:aws:s3:::example", + region: "eu-west-1", + }); + + // Then + expect(context).toEqual({ + kind: "finding", + id: "finding-2", + source: "focused", + scopeKey: "attack-paths:/attack-paths", + label: "Focused finding", + findingId: "finding-2", + checkId: "aws_s3_bucket_public_access", + severity: "critical", + status: "FAIL", + providerUid: "123456789012", + resourceUid: "arn:aws:s3:::example", + region: "eu-west-1", + }); + }); + it("builds resource summary and selected resource snapshots", () => { expect(buildResourceSummaryContext(17)).toMatchObject({ kind: "resource", @@ -107,6 +139,38 @@ describe("Lighthouse page contributions", () => { }); }); + it("builds a focused resource for the owning page scope", () => { + // Given / When + const context = buildFocusedResourceContext({ + pathname: "/resources", + id: "resource-1", + attributes: { + uid: "arn:aws:s3:::example", + service: "s3", + region: "eu-west-1", + type: "AwsS3Bucket", + failed_findings_count: 3, + }, + providerUid: "123456789012", + }); + + // Then + expect(context).toEqual({ + kind: "resource", + id: "resource-1", + source: "focused", + scopeKey: "resources:/resources", + label: "Focused resource", + resourceId: "resource-1", + resourceUid: "arn:aws:s3:::example", + providerUid: "123456789012", + service: "s3", + region: "eu-west-1", + resourceType: "AwsS3Bucket", + failedFindingsCount: 3, + }); + }); + it("builds compliance framework snapshots with score and totals", () => { expect( buildComplianceContext({ diff --git a/ui/lib/lighthouse/context/contributions.ts b/ui/lib/lighthouse/context/contributions.ts index 064b2761d8..131f79d728 100644 --- a/ui/lib/lighthouse/context/contributions.ts +++ b/ui/lib/lighthouse/context/contributions.ts @@ -38,6 +38,10 @@ interface FindingResourceContextInput { region?: string; } +interface FocusedFindingContextInput extends FindingResourceContextInput { + pathname: string; +} + interface ResourceContextInput { id: string; attributes: { @@ -50,6 +54,10 @@ interface ResourceContextInput { providerUid?: string; } +interface FocusedResourceContextInput extends ResourceContextInput { + pathname: string; +} + interface ComplianceContextInput { pathname: string; id: string; @@ -140,6 +148,26 @@ export function buildFindingResourceContext( }; } +export function buildFocusedFindingContext( + finding: FocusedFindingContextInput, +): LighthouseFindingContextItem { + const safeFindingId = toBoundedString(finding.findingId); + return { + kind: LIGHTHOUSE_CONTEXT_KIND.FINDING, + id: safeFindingId, + source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED, + scopeKey: getLighthouseScopeKey(finding.pathname), + label: "Focused finding", + findingId: safeFindingId, + checkId: optionalBoundedString(finding.checkId), + severity: optionalBoundedString(finding.severity), + status: optionalBoundedString(finding.status), + providerUid: optionalBoundedString(finding.providerUid), + resourceUid: optionalBoundedString(finding.resourceUid), + region: optionalBoundedString(finding.region), + }; +} + export function buildResourceSummaryContext( total: number, ): LighthouseResourceContextItem { @@ -174,6 +202,25 @@ export function buildResourceContext( }; } +export function buildFocusedResourceContext( + resource: FocusedResourceContextInput, +): LighthouseResourceContextItem { + return { + kind: LIGHTHOUSE_CONTEXT_KIND.RESOURCE, + id: toBoundedString(resource.id), + source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED, + scopeKey: getLighthouseScopeKey(resource.pathname), + label: "Focused resource", + resourceId: toBoundedString(resource.id), + resourceUid: toBoundedString(resource.attributes.uid), + providerUid: optionalBoundedString(resource.providerUid), + service: toBoundedString(resource.attributes.service), + region: toBoundedString(resource.attributes.region), + resourceType: toBoundedString(resource.attributes.type), + failedFindingsCount: toSafeCount(resource.attributes.failed_findings_count), + }; +} + export function buildComplianceContext( input: ComplianceContextInput, ): LighthouseComplianceContextItem { diff --git a/ui/store/lighthouse-context/store.test.ts b/ui/store/lighthouse-context/store.test.ts index 9b5200e06b..5b14598106 100644 --- a/ui/store/lighthouse-context/store.test.ts +++ b/ui/store/lighthouse-context/store.test.ts @@ -72,4 +72,38 @@ describe("useLighthouseContextStore", () => { ).map((item) => item.id), ).toEqual(["resource-2"]); }); + + it("should keep focused context owned by the latest detail panel", () => { + // Given + const { setFocusedContext, clearFocusedContext } = + useLighthouseContextStore.getState(); + setFocusedContext(1, { + kind: "finding", + id: "finding-1", + source: "focused", + scopeKey: "findings:/findings", + label: "Focused finding", + findingId: "finding-1", + }); + + // When + setFocusedContext(2, { + kind: "resource", + id: "resource-2", + source: "focused", + scopeKey: "resources:/resources", + label: "Focused resource", + resourceId: "resource-2", + }); + clearFocusedContext(1); + + // Then + expect(useLighthouseContextStore.getState().focused?.id).toBe("resource-2"); + + // When + clearFocusedContext(2); + + // Then + expect(useLighthouseContextStore.getState().focused).toBeNull(); + }); }); diff --git a/ui/store/lighthouse-context/store.ts b/ui/store/lighthouse-context/store.ts index c8d4e80dc5..aba88ed7cd 100644 --- a/ui/store/lighthouse-context/store.ts +++ b/ui/store/lighthouse-context/store.ts @@ -4,17 +4,26 @@ import type { LighthouseContextItem } from "@/types/lighthouse-context"; export interface LighthouseContextStoreState { contributions: Record; + focused: LighthouseContextItem | null; + focusedOwnerToken: number; registerContribution: ( contributorId: string, item: LighthouseContextItem, ) => void; removeContribution: (contributorId: string) => void; + setFocusedContext: ( + ownerToken: number, + item: LighthouseContextItem | null, + ) => void; + clearFocusedContext: (ownerToken: number) => void; resetContributions: () => void; } export const useLighthouseContextStore = create( (set) => ({ contributions: {}, + focused: null, + focusedOwnerToken: 0, registerContribution: (contributorId, item) => set((state) => ({ contributions: { @@ -30,7 +39,16 @@ export const useLighthouseContextStore = create( ), ), })), - resetContributions: () => set({ contributions: {} }), + setFocusedContext: (ownerToken, focused) => + set({ focused, focusedOwnerToken: ownerToken }), + clearFocusedContext: (ownerToken) => + set((state) => + state.focusedOwnerToken === ownerToken + ? { focused: null, focusedOwnerToken: 0 } + : state, + ), + resetContributions: () => + set({ contributions: {}, focused: null, focusedOwnerToken: 0 }), }), );