feat(ui): add focused Lighthouse detail context

This commit is contained in:
alejandrobailo
2026-07-22 17:41:54 +02:00
parent fa7c071ba8
commit 144c975d63
19 changed files with 900 additions and 73 deletions
@@ -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(<MessageBubble message={userMessage} />);
// Then
expect(screen.getByText("@ Findings")).toBeInTheDocument();
expect(screen.getByText("@ Findings +1")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Remove Findings context/ }),
).not.toBeInTheDocument();
@@ -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(<LighthousePanelChat />);
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();
@@ -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 {
@@ -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
@@ -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;
}) => (
<>
<output data-testid="focused-context">{JSON.stringify(context)}</output>
{children}
</>
),
}));
vi.mock("./resource-detail-drawer-content", () => ({
ResourceDetailDrawerContent: () => <div>Finding details</div>,
}));
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 (
<ResourceDetailDrawer
open
onOpenChange={vi.fn()}
isLoading={false}
isNavigating={false}
checkMeta={null}
currentIndex={0}
totalResources={2}
currentResource={currentResource}
currentFinding={null}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>
);
}
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,
};
}
@@ -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 (
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title="Resource Finding Details"
description="View finding details for the selected resource"
context={context}
>
<ResourceDetailDrawerContent
isLoading={isLoading}
+111 -4
View File
@@ -20,13 +20,32 @@ describe("LighthouseCurrentContextBadge", () => {
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(<LighthouseCurrentContextBadge context={context} />);
// 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(<LighthouseContextBadge context={findingsContext()} />);
// 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",
},
],
};
}
+76 -25
View File
@@ -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 (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Badge asChild variant="tag">
<span tabIndex={0} aria-label={`${pageLabel} context`}>
{buildContextLabel(pageLabel, selectionCount)}
{buildContextLabel(pageLabel, additionalCount)}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
{pageLabel} context will be included in your next message.
</TooltipContent>
<LighthouseContextTooltip context={context} />
</Tooltip>
);
}
@@ -43,14 +42,14 @@ export function LighthouseContextBadge({
}: {
context: LighthouseContextEnvelope;
}) {
const { pageLabel, selectionCount } = getContextBadgeContent(context);
const { pageLabel, additionalCount } = getContextBadgeContent(context);
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Badge asChild variant="tag">
<span tabIndex={0} aria-label={`Historical ${pageLabel} context`}>
{buildContextLabel(pageLabel, selectionCount)}
{buildContextLabel(pageLabel, additionalCount)}
</span>
</Badge>
</TooltipTrigger>
@@ -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 (
<TooltipContent maxWidth="md">
<div className="space-y-1">
{page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE && (
<p>Page: {page.path}</p>
)}
{filters && <p>Filters: {filters}</p>}
{summaries && <p>Summaries: {summaries}</p>}
<p>Included types: {types}</p>
{itemDescriptions.map(({ id, text }) => (
<p key={id}>{text}</p>
))}
</div>
</TooltipContent>
);
}
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}` : ""}`;
}
@@ -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 (
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title="Resource Details"
description="View the resource details"
context={context}
>
<ResourceDetailContent key={resource.id} resourceDetails={resource} />
</DetailSidePanel>
@@ -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: () => <div>Resource details</div>,
vi.mock("next/navigation", () => ({
usePathname: () => "/resources",
}));
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
context,
children,
}: {
context?: unknown;
children: ReactNode;
}) => (
<>
<output data-testid="focused-context">{JSON.stringify(context)}</output>
{children}
</>
),
}));
vi.mock("./resource-detail-content", () => ({
ResourceDetailContent: () => <div>Resource details</div>,
}));
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(
<ResourcesTableWithSelection
data={[resource]}
@@ -67,10 +121,28 @@ describe("ResourcesTableWithSelection", () => {
'"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();
});
});
@@ -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 && (
<LighthouseContextContributor
key={`resource-${selectedResource.id}`}
contributorId={`resource-${selectedResource.id}`}
item={buildResourceContext({
id: selectedResource.id,
attributes: selectedResource.attributes,
providerUid:
selectedResource.relationships.provider.data.attributes.uid,
})}
/>
)}
<DataTable
columns={columns}
data={safeData}
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
import { DetailSidePanel } from "./detail-side-panel";
@@ -39,6 +40,14 @@ function Host({ initialOpen = true }: { initialOpen?: boolean }) {
onOpenChange={setOpen}
title="Resource Details"
description="View the resource details"
context={{
kind: "finding",
id: "finding-1",
source: "focused",
scopeKey: "findings:/findings",
label: "Focused finding",
findingId: "finding-1",
}}
>
<div data-testid="detail-content">detail body</div>
</DetailSidePanel>
@@ -61,10 +70,34 @@ function DualHost() {
Open B
</button>
<GlobalSidePanel />
<DetailSidePanel open={openA} onOpenChange={setOpenA} title="Finding A">
<DetailSidePanel
open={openA}
onOpenChange={setOpenA}
title="Finding A"
context={{
kind: "finding",
id: "finding-a",
source: "focused",
scopeKey: "findings:/findings",
label: "Focused finding A",
findingId: "finding-a",
}}
>
<div data-testid="detail-a">A body</div>
</DetailSidePanel>
<DetailSidePanel open={openB} onOpenChange={setOpenB} title="Finding B">
<DetailSidePanel
open={openB}
onOpenChange={setOpenB}
title="Finding B"
context={{
kind: "finding",
id: "finding-b",
source: "focused",
scopeKey: "findings:/findings",
label: "Focused finding B",
findingId: "finding-b",
}}
>
<div data-testid="detail-b">B body</div>
</DetailSidePanel>
<output data-testid="open-a">{String(openA)}</output>
@@ -73,6 +106,34 @@ function DualHost() {
);
}
function NavigatingHost() {
const [findingId, setFindingId] = useState("finding-1");
return (
<>
<button type="button" onClick={() => setFindingId("finding-2")}>
Next finding
</button>
<GlobalSidePanel />
<DetailSidePanel
open
onOpenChange={vi.fn()}
title="Finding"
context={{
kind: "finding",
id: findingId,
source: "focused",
scopeKey: "findings:/findings",
label: "Focused finding",
findingId,
}}
>
<div data-testid="navigating-detail">{findingId}</div>
</DetailSidePanel>
</>
);
}
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(<NavigatingHost />);
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");
});
});
+53 -9
View File
@@ -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<DetailSidePanelProps, "open">) {
// 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 ? (
<FocusedContextRegistration
key={`${token}:${JSON.stringify(context)}`}
ownerToken={token}
context={context}
/>
) : null;
return createPortal(
<div className="flex h-full min-h-0 flex-col">
<h2 className="sr-only">{title}</h2>
{description ? <p className="sr-only">{description}</p> : null}
{children}
</div>,
outlet,
if (!outlet || token === null || token !== ownerToken) {
return focusedRegistration;
}
return (
<>
{focusedRegistration}
{createPortal(
<div className="flex h-full min-h-0 flex-col">
<h2 className="sr-only">{title}</h2>
{description ? <p className="sr-only">{description}</p> : null}
{children}
</div>,
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;
}
+42
View File
@@ -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);
});
});
+7 -2
View File
@@ -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,
};
}
@@ -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({
@@ -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 {
+34
View File
@@ -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();
});
});
+19 -1
View File
@@ -4,17 +4,26 @@ import type { LighthouseContextItem } from "@/types/lighthouse-context";
export interface LighthouseContextStoreState {
contributions: Record<string, LighthouseContextItem>;
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<LighthouseContextStoreState>(
(set) => ({
contributions: {},
focused: null,
focusedOwnerToken: 0,
registerContribution: (contributorId, item) =>
set((state) => ({
contributions: {
@@ -30,7 +39,16 @@ export const useLighthouseContextStore = create<LighthouseContextStoreState>(
),
),
})),
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 }),
}),
);