mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): tenant-level model selection and shared business context for Lighthouse
This commit is contained in:
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildLighthouseV2ConfigurationPayload,
|
||||
buildLighthouseV2TenantConfigurationUpdatePayload,
|
||||
mapLighthouseV2Configuration,
|
||||
mapLighthouseV2Message,
|
||||
mapLighthouseV2Model,
|
||||
mapLighthouseV2Provider,
|
||||
mapLighthouseV2TenantConfiguration,
|
||||
validateLighthouseV2ConfigurationInput,
|
||||
} from "./lighthouse-v2.adapter";
|
||||
|
||||
@@ -20,7 +22,6 @@ describe("lighthouse-v2.adapter", () => {
|
||||
provider_type: "bedrock",
|
||||
base_url: null,
|
||||
default_model: "anthropic.claude",
|
||||
business_context: "Production AWS account",
|
||||
connected: true,
|
||||
connection_last_checked_at: "2026-06-24T10:00:00Z",
|
||||
inserted_at: "2026-06-24T09:00:00Z",
|
||||
@@ -37,7 +38,6 @@ describe("lighthouse-v2.adapter", () => {
|
||||
providerType: "bedrock",
|
||||
baseUrl: null,
|
||||
defaultModel: "anthropic.claude",
|
||||
businessContext: "Production AWS account",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||
insertedAt: "2026-06-24T09:00:00Z",
|
||||
@@ -79,6 +79,37 @@ describe("lighthouse-v2.adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should map tenant model defaults by provider", () => {
|
||||
// Given
|
||||
const resource: Parameters<typeof mapLighthouseV2TenantConfiguration>[0] =
|
||||
{
|
||||
id: "tenant-config-1",
|
||||
type: "lighthouse-configurations",
|
||||
attributes: {
|
||||
business_context: "Production tenant",
|
||||
default_provider: "bedrock",
|
||||
default_models: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const config = mapLighthouseV2TenantConfiguration(resource);
|
||||
|
||||
// Then
|
||||
expect(config).toEqual({
|
||||
id: "tenant-config-1",
|
||||
businessContext: "Production tenant",
|
||||
defaultProvider: "bedrock",
|
||||
defaultModels: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should map message parts from backend names", () => {
|
||||
// Given
|
||||
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
|
||||
@@ -122,8 +153,6 @@ describe("lighthouse-v2.adapter", () => {
|
||||
// Given
|
||||
const input = {
|
||||
providerType: "bedrock" as const,
|
||||
defaultModel: "anthropic.claude",
|
||||
businessContext: "Production AWS account",
|
||||
credentials: {
|
||||
aws_access_key_id: "AKIA0000000000000000",
|
||||
aws_secret_access_key: "a".repeat(40),
|
||||
@@ -146,6 +175,36 @@ describe("lighthouse-v2.adapter", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(payload.data.attributes).not.toHaveProperty("default_model");
|
||||
expect(payload.data.attributes).not.toHaveProperty("business_context");
|
||||
});
|
||||
|
||||
it("should build tenant preference payloads with provider model maps", () => {
|
||||
// Given
|
||||
const input = {
|
||||
defaultProvider: "bedrock" as const,
|
||||
defaultModels: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const payload = buildLighthouseV2TenantConfigurationUpdatePayload(input);
|
||||
|
||||
// Then
|
||||
expect(payload).toEqual({
|
||||
data: {
|
||||
type: "lighthouse-configurations",
|
||||
attributes: {
|
||||
default_provider: "bedrock",
|
||||
default_models: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should require base_url for OpenAI-compatible configurations", () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
type LighthouseV2SupportedModel,
|
||||
type LighthouseV2SupportedProvider,
|
||||
type LighthouseV2Task,
|
||||
type LighthouseV2TenantConfiguration,
|
||||
type LighthouseV2TenantConfigurationUpdateInput,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
export interface JsonApiResource<TAttributes> {
|
||||
@@ -32,14 +34,19 @@ export interface JsonApiDocument<TData> {
|
||||
interface ConfigurationAttributes {
|
||||
provider_type: LighthouseV2ProviderType;
|
||||
base_url: string | null;
|
||||
default_model: string | null;
|
||||
business_context?: string | null;
|
||||
default_model?: string | null;
|
||||
connected: boolean | null;
|
||||
connection_last_checked_at: string | null;
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface TenantConfigurationAttributes {
|
||||
business_context?: string | null;
|
||||
default_provider?: LighthouseV2ProviderType | "";
|
||||
default_models?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
interface SupportedProviderAttributes {
|
||||
name: string;
|
||||
}
|
||||
@@ -114,8 +121,7 @@ export function mapLighthouseV2Configuration(
|
||||
id: resource.id,
|
||||
providerType: resource.attributes.provider_type,
|
||||
baseUrl: resource.attributes.base_url,
|
||||
defaultModel: resource.attributes.default_model,
|
||||
businessContext: resource.attributes.business_context ?? "",
|
||||
defaultModel: resource.attributes.default_model ?? null,
|
||||
connected: resource.attributes.connected,
|
||||
connectionLastCheckedAt: resource.attributes.connection_last_checked_at,
|
||||
insertedAt: resource.attributes.inserted_at,
|
||||
@@ -123,6 +129,17 @@ export function mapLighthouseV2Configuration(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLighthouseV2TenantConfiguration(
|
||||
resource: JsonApiResource<TenantConfigurationAttributes>,
|
||||
): LighthouseV2TenantConfiguration {
|
||||
return {
|
||||
id: resource.id,
|
||||
businessContext: resource.attributes.business_context ?? "",
|
||||
defaultProvider: resource.attributes.default_provider ?? "",
|
||||
defaultModels: resource.attributes.default_models ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLighthouseV2Provider(
|
||||
resource: JsonApiResource<SupportedProviderAttributes>,
|
||||
): LighthouseV2SupportedProvider {
|
||||
@@ -195,8 +212,6 @@ export function buildLighthouseV2ConfigurationPayload(
|
||||
provider_type: input.providerType,
|
||||
credentials: input.credentials,
|
||||
base_url: input.baseUrl ?? null,
|
||||
default_model: input.defaultModel ?? null,
|
||||
business_context: input.businessContext,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -213,8 +228,21 @@ export function buildLighthouseV2ConfigurationUpdatePayload(
|
||||
attributes: filterUndefinedAttributes({
|
||||
credentials: input.credentials,
|
||||
base_url: input.baseUrl,
|
||||
default_model: input.defaultModel,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLighthouseV2TenantConfigurationUpdatePayload(
|
||||
input: LighthouseV2TenantConfigurationUpdateInput,
|
||||
) {
|
||||
return {
|
||||
data: {
|
||||
type: "lighthouse-configurations",
|
||||
attributes: filterUndefinedAttributes({
|
||||
business_context: input.businessContext,
|
||||
default_provider: input.defaultProvider,
|
||||
default_models: input.defaultModels,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/lib/helper", () => ({
|
||||
import {
|
||||
createLighthouseV2Session,
|
||||
updateLighthouseV2Session,
|
||||
updateLighthouseV2TenantConfiguration,
|
||||
} from "./lighthouse-v2";
|
||||
|
||||
function sessionResponse(id = "session-1") {
|
||||
@@ -48,6 +49,26 @@ function sessionResponse(id = "session-1") {
|
||||
);
|
||||
}
|
||||
|
||||
function tenantConfigurationResponse() {
|
||||
return Response.json(
|
||||
{
|
||||
data: {
|
||||
id: "tenant-config-1",
|
||||
type: "lighthouse-configurations",
|
||||
attributes: {
|
||||
business_context: "Production tenant",
|
||||
default_provider: "bedrock",
|
||||
default_models: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
describe("Lighthouse v2 session write actions", () => {
|
||||
beforeEach(() => {
|
||||
authMock.mockResolvedValue({ accessToken: "token-123" });
|
||||
@@ -77,4 +98,41 @@ describe("Lighthouse v2 session write actions", () => {
|
||||
// Proves the test harness would catch a revalidate being (re)introduced.
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse");
|
||||
});
|
||||
|
||||
it("updates tenant model preferences without revalidating the active chat route", async () => {
|
||||
// Given
|
||||
const fetchMock = vi.fn().mockResolvedValue(tenantConfigurationResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// When
|
||||
const result = await updateLighthouseV2TenantConfiguration({
|
||||
defaultProvider: "bedrock",
|
||||
defaultModels: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect("data" in result && result.data.defaultProvider).toBe("bedrock");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL("https://api.example.com/api/v1/lighthouse/configuration"),
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "lighthouse-configurations",
|
||||
attributes: {
|
||||
default_provider: "bedrock",
|
||||
default_models: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(revalidatePathMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
LighthouseV2SupportedModel,
|
||||
LighthouseV2SupportedProvider,
|
||||
LighthouseV2Task,
|
||||
LighthouseV2TenantConfiguration,
|
||||
LighthouseV2TenantConfigurationUpdateInput,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
buildLighthouseV2MessagePayload,
|
||||
buildLighthouseV2SessionCreatePayload,
|
||||
buildLighthouseV2SessionUpdatePayload,
|
||||
buildLighthouseV2TenantConfigurationUpdatePayload,
|
||||
getJsonApiArray,
|
||||
type JsonApiDocument,
|
||||
mapLighthouseV2Configuration,
|
||||
@@ -32,6 +35,7 @@ import {
|
||||
mapLighthouseV2Provider,
|
||||
mapLighthouseV2Session,
|
||||
mapLighthouseV2Task,
|
||||
mapLighthouseV2TenantConfiguration,
|
||||
validateLighthouseV2ConfigurationInput,
|
||||
} from "./lighthouse-v2.adapter";
|
||||
|
||||
@@ -102,6 +106,31 @@ export async function deleteLighthouseV2Configuration(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLighthouseV2TenantConfiguration(): Promise<
|
||||
LighthouseV2ActionResult<LighthouseV2TenantConfiguration>
|
||||
> {
|
||||
return getSingle(
|
||||
"/lighthouse/configuration",
|
||||
mapLighthouseV2TenantConfiguration,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateLighthouseV2TenantConfiguration(
|
||||
input: LighthouseV2TenantConfigurationUpdateInput,
|
||||
): Promise<LighthouseV2ActionResult<LighthouseV2TenantConfiguration>> {
|
||||
return mutateSingle(
|
||||
"/lighthouse/configuration",
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(
|
||||
buildLighthouseV2TenantConfigurationUpdatePayload(input),
|
||||
),
|
||||
},
|
||||
mapLighthouseV2TenantConfiguration,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// Starts the backend connection-check task, polls it to completion (reusing the
|
||||
// shared task poller), then returns the re-fetched configuration so the caller
|
||||
// can render the authoritative `connected` / `connectionLastCheckedAt` status.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { CornerDownLeft, Settings } from "lucide-react";
|
||||
import { CornerDownLeft, Settings, TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FormEvent } from "react";
|
||||
import { type ReactNode, type SubmitEvent } from "react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
@@ -17,13 +18,15 @@ interface ChatComposerPanelProps {
|
||||
feedback: string | null;
|
||||
canRetry: boolean;
|
||||
onRetry: () => void;
|
||||
onDismissFeedback: () => void;
|
||||
canSend: boolean;
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
modelSelector: ReactNode;
|
||||
selectedConfigurationConnected: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onStop: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||
onSubmitText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -33,6 +36,7 @@ export function ChatComposerPanel({
|
||||
feedback,
|
||||
canRetry,
|
||||
onRetry,
|
||||
onDismissFeedback,
|
||||
...composerProps
|
||||
}: ChatComposerPanelProps) {
|
||||
return (
|
||||
@@ -41,6 +45,7 @@ export function ChatComposerPanel({
|
||||
feedback={feedback}
|
||||
canRetry={canRetry}
|
||||
onRetry={onRetry}
|
||||
onDismiss={onDismissFeedback}
|
||||
/>
|
||||
<ChatComposer {...composerProps} />
|
||||
</>
|
||||
@@ -51,22 +56,27 @@ function ChatFeedbackBar({
|
||||
feedback,
|
||||
canRetry,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: {
|
||||
feedback: string | null;
|
||||
canRetry: boolean;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
if (!feedback) return null;
|
||||
|
||||
return (
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary mb-3 flex items-center justify-between gap-3 rounded-[8px] border px-3 py-2 text-sm">
|
||||
<span>{feedback}</span>
|
||||
{canRetry && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Alert variant="error" onClose={onDismiss} className="mb-3 pr-10">
|
||||
<TriangleAlert />
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>{feedback}</span>
|
||||
{canRetry && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,12 +84,13 @@ interface ChatComposerProps {
|
||||
canSend: boolean;
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
modelSelector: ReactNode;
|
||||
selectedConfigurationConnected: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
// Kept on the contract but unused for now: the backend can't cancel a run yet,
|
||||
// so the stop control is replaced by a non-interactive spinner.
|
||||
onStop: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||
onSubmitText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -89,6 +100,7 @@ function ChatComposer({
|
||||
isStreaming,
|
||||
selectedConfigurationConnected,
|
||||
onInputChange,
|
||||
modelSelector,
|
||||
onSubmit,
|
||||
onSubmitText,
|
||||
}: ChatComposerProps) {
|
||||
@@ -117,12 +129,18 @@ function ChatComposer({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3 pb-3">
|
||||
<Button type="button" variant="outline" size="icon-sm" asChild>
|
||||
<Link href="/lighthouse/settings" aria-label="Lighthouse AI settings">
|
||||
<Settings className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3 px-3 pb-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Button type="button" variant="outline" size="icon-sm" asChild>
|
||||
<Link
|
||||
href="/lighthouse/settings"
|
||||
aria-label="Lighthouse AI settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{modelSelector}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<div
|
||||
className="flex size-8 items-center justify-center"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, FileCheck2, Network, ShieldAlert } from "lucide-react";
|
||||
import { type FormEvent } from "react";
|
||||
import { type ReactNode, type SubmitEvent } from "react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
@@ -36,13 +36,15 @@ interface ChatEmptyStateProps {
|
||||
feedback: string | null;
|
||||
canRetry: boolean;
|
||||
onRetry: () => void;
|
||||
onDismissFeedback: () => void;
|
||||
canSend: boolean;
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
modelSelector: ReactNode;
|
||||
selectedConfigurationConnected: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onStop: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||
onSubmitText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,19 +57,26 @@ function stubEventSource() {
|
||||
vi.stubGlobal("EventSource", EventSourceMock);
|
||||
}
|
||||
|
||||
const { cancelRunMock, createSessionMock, getMessagesMock, sendMessageMock } =
|
||||
vi.hoisted(() => ({
|
||||
cancelRunMock: vi.fn(),
|
||||
createSessionMock: vi.fn(),
|
||||
getMessagesMock: vi.fn(),
|
||||
sendMessageMock: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
cancelRunMock,
|
||||
createSessionMock,
|
||||
getMessagesMock,
|
||||
sendMessageMock,
|
||||
updateTenantConfigurationMock,
|
||||
} = vi.hoisted(() => ({
|
||||
cancelRunMock: vi.fn(),
|
||||
createSessionMock: vi.fn(),
|
||||
getMessagesMock: vi.fn(),
|
||||
sendMessageMock: vi.fn(),
|
||||
updateTenantConfigurationMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
cancelLighthouseV2Run: cancelRunMock,
|
||||
createLighthouseV2Session: createSessionMock,
|
||||
getLighthouseV2Messages: getMessagesMock,
|
||||
sendLighthouseV2Message: sendMessageMock,
|
||||
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
|
||||
}));
|
||||
|
||||
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
|
||||
@@ -85,7 +92,6 @@ const configurations: LighthouseV2Configuration[] = [
|
||||
providerType: "openai",
|
||||
baseUrl: null,
|
||||
defaultModel: "gpt-5.1",
|
||||
businessContext: "Production account",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||
insertedAt: "2026-06-24T09:00:00Z",
|
||||
@@ -96,7 +102,6 @@ const configurations: LighthouseV2Configuration[] = [
|
||||
providerType: "bedrock",
|
||||
baseUrl: null,
|
||||
defaultModel: "anthropic.claude-4",
|
||||
businessContext: "AWS landing zone",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
||||
insertedAt: "2026-06-23T09:00:00Z",
|
||||
@@ -105,11 +110,21 @@ const configurations: LighthouseV2Configuration[] = [
|
||||
];
|
||||
|
||||
const modelsByProvider = {
|
||||
openai: [model("gpt-5.1")],
|
||||
openai: [model("gpt-5.1"), model("gpt-4.1")],
|
||||
bedrock: [model("anthropic.claude-4")],
|
||||
"openai-compatible": [model("llama-3.3")],
|
||||
};
|
||||
|
||||
const tenantConfiguration = {
|
||||
id: "tenant-config-1",
|
||||
businessContext: "Production account",
|
||||
defaultProvider: "openai",
|
||||
defaultModels: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
} satisfies Parameters<typeof LighthouseV2ChatPage>[0]["tenantConfiguration"];
|
||||
|
||||
describe("LighthouseV2ChatPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
@@ -120,10 +135,15 @@ describe("LighthouseV2ChatPage", () => {
|
||||
disconnect = vi.fn();
|
||||
},
|
||||
);
|
||||
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
cancelRunMock.mockReset();
|
||||
createSessionMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
sendMessageMock.mockReset();
|
||||
updateTenantConfigurationMock.mockReset();
|
||||
// The mock never fires "open": the client must POST the message without
|
||||
// waiting for it (the backend sends no bytes until the worker emits, which
|
||||
// only happens after the POST). This is the regression guard for the
|
||||
@@ -150,18 +170,21 @@ describe("LighthouseV2ChatPage", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
updateTenantConfigurationMock.mockResolvedValue({
|
||||
data: tenantConfiguration,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not render provider or model selectors in the chat composer", () => {
|
||||
it("renders the searchable model selector and settings shortcut", () => {
|
||||
// Given / When
|
||||
renderPage();
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Lighthouse AI settings" }),
|
||||
).toHaveAttribute("href", "/lighthouse/settings");
|
||||
@@ -206,11 +229,16 @@ describe("LighthouseV2ChatPage", () => {
|
||||
).not.toHaveClass("border-t");
|
||||
});
|
||||
|
||||
it("sends messages with the connected default provider and model from configuration", async () => {
|
||||
it("sends messages with the tenant default provider and its saved model", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
renderPage();
|
||||
renderPage({
|
||||
tenantConfiguration: {
|
||||
...tenantConfiguration,
|
||||
defaultProvider: "bedrock",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
@@ -223,8 +251,8 @@ describe("LighthouseV2ChatPage", () => {
|
||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
text: "Summarize findings",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
provider: "bedrock",
|
||||
model: "anthropic.claude-4",
|
||||
}),
|
||||
);
|
||||
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
|
||||
@@ -242,6 +270,84 @@ describe("LighthouseV2ChatPage", () => {
|
||||
replaceStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to the first connected provider when tenant defaults are invalid", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage({
|
||||
tenantConfiguration: {
|
||||
...tenantConfiguration,
|
||||
defaultProvider: "bedrock",
|
||||
defaultModels: {
|
||||
bedrock: "missing-model",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Message" }),
|
||||
["Summarize findings", "{Enter}"].join(""),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists the selected chat model without deleting other provider defaults", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "anthropic.claude-4" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(updateTenantConfigurationMock).toHaveBeenCalledWith({
|
||||
defaultProvider: "bedrock",
|
||||
defaultModels: {
|
||||
openai: "gpt-5.1",
|
||||
bedrock: "anthropic.claude-4",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the chosen model applied and surfaces the backend reason when saving the default fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
updateTenantConfigurationMock.mockResolvedValue({
|
||||
error: "Invalid model 'anthropic.claude-4' for provider 'bedrock'.",
|
||||
status: 400,
|
||||
});
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Model" }));
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "anthropic.claude-4" }),
|
||||
);
|
||||
|
||||
// Then: the failed save shows the real backend message as an error alert,
|
||||
// and the selection stays applied so the connected provider remains usable.
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Invalid model 'anthropic.claude-4' for provider 'bedrock'.",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent(
|
||||
"anthropic.claude-4",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the URL before notifying session history listeners", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -364,6 +470,7 @@ function renderPage(props?: RenderPageProps) {
|
||||
const componentProps = {
|
||||
configurations: props?.configurations ?? configurations,
|
||||
modelsByProvider: props?.modelsByProvider ?? modelsByProvider,
|
||||
tenantConfiguration: props?.tenantConfiguration ?? tenantConfiguration,
|
||||
initialSessionId: props?.initialSessionId,
|
||||
initialMessages: props?.initialMessages ?? [],
|
||||
initialPrompt: props?.initialPrompt,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
import { type SubmitEvent, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
cancelLighthouseV2Run,
|
||||
createLighthouseV2Session,
|
||||
getLighthouseV2Messages,
|
||||
sendLighthouseV2Message,
|
||||
updateLighthouseV2TenantConfiguration,
|
||||
} from "@/app/(prowler)/lighthouse/_actions";
|
||||
import {
|
||||
Conversation,
|
||||
@@ -22,6 +23,11 @@ import {
|
||||
buildOptimisticMessage,
|
||||
buildSessionTitle,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/messages";
|
||||
import {
|
||||
buildLighthouseV2ModelSelectionValue,
|
||||
type LighthouseV2ModelSelection,
|
||||
parseLighthouseV2ModelSelectionValue,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/model-selection";
|
||||
import {
|
||||
LIGHTHOUSE_V2_NEW_CHAT_EVENT,
|
||||
notifyLighthouseV2SessionsChanged,
|
||||
@@ -29,15 +35,19 @@ import {
|
||||
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
|
||||
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
|
||||
import {
|
||||
LIGHTHOUSE_V2_PROVIDER_TYPE,
|
||||
LIGHTHOUSE_V2_SSE_EVENT,
|
||||
type LighthouseV2Configuration,
|
||||
type LighthouseV2Message,
|
||||
type LighthouseV2ProviderType,
|
||||
type LighthouseV2SSEEvent,
|
||||
type LighthouseV2SupportedModel,
|
||||
type LighthouseV2TenantConfiguration,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { Card } from "@/components/shadcn";
|
||||
import {
|
||||
Combobox,
|
||||
type ComboboxGroup,
|
||||
} from "@/components/shadcn/combobox/combobox";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
|
||||
import { ChatComposerPanel } from "./composer";
|
||||
@@ -51,6 +61,7 @@ interface LighthouseV2ChatPageProps {
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel[]
|
||||
>;
|
||||
tenantConfiguration?: LighthouseV2TenantConfiguration;
|
||||
initialSessionId?: string;
|
||||
initialMessages: LighthouseV2Message[];
|
||||
initialActiveTaskId?: string | null;
|
||||
@@ -61,6 +72,7 @@ interface LighthouseV2ChatPageProps {
|
||||
export function LighthouseV2ChatPage({
|
||||
configurations,
|
||||
modelsByProvider,
|
||||
tenantConfiguration,
|
||||
initialSessionId,
|
||||
initialMessages,
|
||||
initialActiveTaskId,
|
||||
@@ -72,13 +84,17 @@ export function LighthouseV2ChatPage({
|
||||
const connectedConfigurations = configurations.filter(
|
||||
(configuration) => configuration.connected === true,
|
||||
);
|
||||
const selectedConfiguration = connectedConfigurations[0] ?? configurations[0];
|
||||
const selectedProvider =
|
||||
selectedConfiguration?.providerType ?? LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
|
||||
const selectedModel =
|
||||
selectedConfiguration?.defaultModel ??
|
||||
modelsByProvider[selectedProvider]?.[0]?.id ??
|
||||
"";
|
||||
const initialModelSelection = resolveInitialModelSelection(
|
||||
connectedConfigurations,
|
||||
modelsByProvider,
|
||||
tenantConfiguration,
|
||||
);
|
||||
const [selectedModelSelection, setSelectedModelSelection] =
|
||||
useState<LighthouseV2ModelSelection | null>(initialModelSelection);
|
||||
const [tenantModelDefaults, setTenantModelDefaults] = useState<
|
||||
Record<string, string>
|
||||
>(tenantConfiguration?.defaultModels ?? {});
|
||||
const [modelPreferenceSaving, setModelPreferenceSaving] = useState(false);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(
|
||||
initialSessionId ?? null,
|
||||
);
|
||||
@@ -93,9 +109,26 @@ export function LighthouseV2ChatPage({
|
||||
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
|
||||
createInitialLighthouseV2StreamState(initialActiveTaskId ?? null),
|
||||
);
|
||||
const selectedConfiguration = selectedModelSelection
|
||||
? connectedConfigurations.find(
|
||||
(configuration) =>
|
||||
configuration.providerType === selectedModelSelection.providerType,
|
||||
)
|
||||
: undefined;
|
||||
const modelSelectorGroups = buildModelSelectorGroups(
|
||||
connectedConfigurations,
|
||||
modelsByProvider,
|
||||
);
|
||||
const selectedModelValue = selectedModelSelection
|
||||
? buildLighthouseV2ModelSelectionValue(
|
||||
selectedModelSelection.providerType,
|
||||
selectedModelSelection.modelId,
|
||||
)
|
||||
: "";
|
||||
|
||||
const canSend =
|
||||
selectedConfiguration?.connected === true &&
|
||||
Boolean(selectedModelSelection?.modelId) &&
|
||||
!streamState.activeTaskId &&
|
||||
!blockedByConflict &&
|
||||
!isSubmitting;
|
||||
@@ -213,7 +246,12 @@ export function LighthouseV2ChatPage({
|
||||
|
||||
const submitMessage = async (text: string) => {
|
||||
const trimmedText = text.trim();
|
||||
if (!trimmedText || !canSend) return;
|
||||
if (!trimmedText) return;
|
||||
if (!selectedModelSelection) {
|
||||
setFeedback("Select a model before sending a message.");
|
||||
return;
|
||||
}
|
||||
if (!canSend) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
@@ -239,8 +277,8 @@ export function LighthouseV2ChatPage({
|
||||
const result = await sendLighthouseV2Message({
|
||||
sessionId,
|
||||
text: trimmedText,
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
provider: selectedModelSelection.providerType,
|
||||
model: selectedModelSelection.modelId,
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
@@ -265,7 +303,48 @@ export function LighthouseV2ChatPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
const handleModelValueChange = (value: string) => {
|
||||
const selection = parseLighthouseV2ModelSelectionValue(value);
|
||||
if (!selection) return;
|
||||
void handleModelSelectionChange(selection);
|
||||
};
|
||||
|
||||
const handleModelSelectionChange = async (
|
||||
selection: LighthouseV2ModelSelection,
|
||||
) => {
|
||||
// The selection drives the model used for the next message, so it stays
|
||||
// applied even if persisting it as the tenant default fails. Only the
|
||||
// saved-default mirror is rolled back on failure — reverting the active
|
||||
// selection would make a connected provider unusable when the save 4xxs.
|
||||
const previousDefaults = tenantModelDefaults;
|
||||
|
||||
setSelectedModelSelection(selection);
|
||||
setFeedback(null);
|
||||
|
||||
const nextDefaults = {
|
||||
...tenantModelDefaults,
|
||||
[selection.providerType]: selection.modelId,
|
||||
};
|
||||
setTenantModelDefaults(nextDefaults);
|
||||
setModelPreferenceSaving(true);
|
||||
|
||||
const result = await updateLighthouseV2TenantConfiguration({
|
||||
defaultProvider: selection.providerType,
|
||||
defaultModels: nextDefaults,
|
||||
});
|
||||
|
||||
setModelPreferenceSaving(false);
|
||||
|
||||
if ("error" in result) {
|
||||
setTenantModelDefaults(previousDefaults);
|
||||
setFeedback(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setTenantModelDefaults(result.data.defaultModels);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
void submitMessage(input);
|
||||
};
|
||||
@@ -337,9 +416,25 @@ export function LighthouseV2ChatPage({
|
||||
streamState.status === "disconnected" && lastSubmittedText !== null,
|
||||
onRetry: () =>
|
||||
lastSubmittedText ? void submitMessage(lastSubmittedText) : undefined,
|
||||
onDismissFeedback: () => setFeedback(null),
|
||||
canSend,
|
||||
input,
|
||||
isStreaming: Boolean(streamState.activeTaskId),
|
||||
modelSelector: (
|
||||
<div className="min-w-0 flex-1 sm:max-w-80">
|
||||
<Combobox
|
||||
aria-label="Model"
|
||||
value={selectedModelValue}
|
||||
onValueChange={handleModelValueChange}
|
||||
groups={modelSelectorGroups}
|
||||
disabled={modelSelectorGroups.length === 0 || modelPreferenceSaving}
|
||||
placeholder="Select model"
|
||||
searchPlaceholder="Search models..."
|
||||
emptyMessage="No models found."
|
||||
triggerClassName="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
selectedConfigurationConnected: selectedConfiguration?.connected === true,
|
||||
onInputChange: setInput,
|
||||
onStop: handleStop,
|
||||
@@ -397,3 +492,101 @@ function replaceLighthouseV2SessionUrl(sessionId: string | null) {
|
||||
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
}
|
||||
|
||||
function resolveInitialModelSelection(
|
||||
connectedConfigurations: LighthouseV2Configuration[],
|
||||
modelsByProvider: Record<
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel[]
|
||||
>,
|
||||
tenantConfiguration?: LighthouseV2TenantConfiguration,
|
||||
): LighthouseV2ModelSelection | null {
|
||||
const tenantDefaultProvider = tenantConfiguration?.defaultProvider;
|
||||
|
||||
if (tenantDefaultProvider) {
|
||||
const defaultModel =
|
||||
tenantConfiguration.defaultModels[tenantDefaultProvider];
|
||||
if (
|
||||
defaultModel &&
|
||||
hasConnectedModel(
|
||||
connectedConfigurations,
|
||||
modelsByProvider,
|
||||
tenantDefaultProvider,
|
||||
defaultModel,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
providerType: tenantDefaultProvider,
|
||||
modelId: defaultModel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const configuration of connectedConfigurations) {
|
||||
const providerModels = modelsByProvider[configuration.providerType] ?? [];
|
||||
const savedModel =
|
||||
tenantConfiguration?.defaultModels[configuration.providerType];
|
||||
const model =
|
||||
providerModels.find((candidate) => candidate.id === savedModel) ??
|
||||
providerModels[0];
|
||||
|
||||
if (model) {
|
||||
return {
|
||||
providerType: configuration.providerType,
|
||||
modelId: model.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasConnectedModel(
|
||||
connectedConfigurations: LighthouseV2Configuration[],
|
||||
modelsByProvider: Record<
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel[]
|
||||
>,
|
||||
providerType: LighthouseV2ProviderType,
|
||||
modelId: string,
|
||||
) {
|
||||
return (
|
||||
connectedConfigurations.some(
|
||||
(configuration) => configuration.providerType === providerType,
|
||||
) &&
|
||||
(modelsByProvider[providerType] ?? []).some((model) => model.id === modelId)
|
||||
);
|
||||
}
|
||||
|
||||
function buildModelSelectorGroups(
|
||||
connectedConfigurations: LighthouseV2Configuration[],
|
||||
modelsByProvider: Record<
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel[]
|
||||
>,
|
||||
): ComboboxGroup[] {
|
||||
return connectedConfigurations
|
||||
.map((configuration) => ({
|
||||
heading: getLighthouseV2ProviderLabel(configuration.providerType),
|
||||
options: (modelsByProvider[configuration.providerType] ?? []).map(
|
||||
(model) => ({
|
||||
value: buildLighthouseV2ModelSelectionValue(
|
||||
configuration.providerType,
|
||||
model.id,
|
||||
),
|
||||
label: model.id,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0);
|
||||
}
|
||||
|
||||
function getLighthouseV2ProviderLabel(providerType: LighthouseV2ProviderType) {
|
||||
return LIGHTHOUSE_V2_PROVIDER_LABELS[providerType] ?? providerType;
|
||||
}
|
||||
|
||||
const LIGHTHOUSE_V2_PROVIDER_LABELS = {
|
||||
openai: "OpenAI",
|
||||
bedrock: "Amazon Bedrock",
|
||||
"openai-compatible": "OpenAI Compatible",
|
||||
} as const satisfies Record<LighthouseV2ProviderType, string>;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LighthouseV2BusinessContextForm } from "./business-context-form";
|
||||
|
||||
const { updateTenantConfigurationMock } = vi.hoisted(() => ({
|
||||
updateTenantConfigurationMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
|
||||
}));
|
||||
|
||||
function tenantConfig(businessContext: string) {
|
||||
return {
|
||||
id: "tenant-config-1",
|
||||
businessContext,
|
||||
defaultProvider: "" as const,
|
||||
defaultModels: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("LighthouseV2BusinessContextForm", () => {
|
||||
beforeEach(() => {
|
||||
updateTenantConfigurationMock.mockReset();
|
||||
updateTenantConfigurationMock.mockResolvedValue({
|
||||
data: tenantConfig("Production context"),
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds the textarea with the tenant business context and disables save until edited", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseV2BusinessContextForm initialBusinessContext="Production context" />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
).toHaveValue("Production context");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save business context" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves the shared business context to the tenant configuration", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
"Production context",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Save business context" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(updateTenantConfigurationMock).toHaveBeenCalledWith({
|
||||
businessContext: "Production context",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the counter and blocks saving over the character limit", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
|
||||
|
||||
// When: paste in one event instead of 1001 keystrokes
|
||||
await user.click(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
);
|
||||
await user.paste("a".repeat(1001));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("1001/1000")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Business context cannot exceed 1000 characters."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save business context" }),
|
||||
).toBeDisabled();
|
||||
expect(updateTenantConfigurationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the backend reason when the save fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
updateTenantConfigurationMock.mockResolvedValue({
|
||||
error: "No active configuration found for 'bedrock'.",
|
||||
status: 400,
|
||||
});
|
||||
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
"Production context",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Save business context" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("No active configuration found for 'bedrock'."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { updateLighthouseV2TenantConfiguration } from "@/app/(prowler)/lighthouse/_actions";
|
||||
import { BUSINESS_CONTEXT_LIMIT } from "@/app/(prowler)/lighthouse/_lib/config";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Tenant-wide business context. It is shared across every provider and chat, so
|
||||
// it is edited once here rather than per provider configuration.
|
||||
export function LighthouseV2BusinessContextForm({
|
||||
initialBusinessContext,
|
||||
}: {
|
||||
initialBusinessContext: string;
|
||||
}) {
|
||||
const [businessContext, setBusinessContext] = useState(
|
||||
initialBusinessContext,
|
||||
);
|
||||
const [savedContext, setSavedContext] = useState(initialBusinessContext);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const overLimit = businessContext.length > BUSINESS_CONTEXT_LIMIT;
|
||||
const isDirty = businessContext !== savedContext;
|
||||
const canSave = isDirty && !overLimit && !saving;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await updateLighthouseV2TenantConfiguration({
|
||||
businessContext,
|
||||
});
|
||||
setSaving(false);
|
||||
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSavedContext(result.data.businessContext);
|
||||
setBusinessContext(result.data.businessContext);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="lighthouse-v2-business-context"
|
||||
className="border-border-neutral-secondary border-b"
|
||||
>
|
||||
<div className="border-border-neutral-secondary flex items-start gap-3 border-b px-4 py-4 md:px-5">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
|
||||
<Bot className="text-text-neutral-secondary size-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-text-neutral-primary text-xl font-semibold">
|
||||
Business context
|
||||
</h3>
|
||||
<p className="text-text-neutral-secondary mt-1 max-w-2xl text-sm">
|
||||
Shared context Lighthouse AI considers for every provider and chat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-4 md:px-5">
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel htmlFor="lighthouse-v2-business-context">
|
||||
Business context
|
||||
</FieldLabel>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
overLimit
|
||||
? "text-text-error-primary"
|
||||
: "text-text-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
{businessContext.length}/{BUSINESS_CONTEXT_LIMIT}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="lighthouse-v2-business-context"
|
||||
textareaSize="lg"
|
||||
aria-invalid={overLimit}
|
||||
value={businessContext}
|
||||
onChange={(event) => setBusinessContext(event.target.value)}
|
||||
placeholder="Example: production AWS accounts, PCI workloads, EU data residency, critical internet-facing services..."
|
||||
/>
|
||||
{overLimit && (
|
||||
<FieldError>
|
||||
Business context cannot exceed {BUSINESS_CONTEXT_LIMIT}{" "}
|
||||
characters.
|
||||
</FieldError>
|
||||
)}
|
||||
{error && <FieldError>{error}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Save business context"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Bot,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
PlugZap,
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
createLighthouseV2Configuration,
|
||||
@@ -22,7 +14,6 @@ import {
|
||||
import {
|
||||
buildCredentialPayload,
|
||||
buildLighthouseV2ConfigFormSchema,
|
||||
BUSINESS_CONTEXT_LIMIT,
|
||||
EMPTY_FORM_VALUES,
|
||||
FEEDBACK_VARIANT,
|
||||
type FeedbackState,
|
||||
@@ -36,21 +27,10 @@ import {
|
||||
type LighthouseV2Configuration,
|
||||
type LighthouseV2ConfigurationInput,
|
||||
type LighthouseV2ConfigurationUpdateInput,
|
||||
type LighthouseV2SupportedModel,
|
||||
type LighthouseV2SupportedProvider,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ConfigurationSection } from "./configuration-section";
|
||||
import { CredentialFields } from "./credential-fields";
|
||||
@@ -59,7 +39,6 @@ import { StatusBadge } from "./status-badge";
|
||||
|
||||
export function LighthouseV2ConfigurationForm({
|
||||
configuration,
|
||||
models,
|
||||
onConfigurationDeleted,
|
||||
onConfigurationSaved,
|
||||
onConfigurationTested,
|
||||
@@ -67,7 +46,6 @@ export function LighthouseV2ConfigurationForm({
|
||||
provider,
|
||||
}: {
|
||||
configuration?: LighthouseV2Configuration;
|
||||
models: LighthouseV2SupportedModel[];
|
||||
onConfigurationDeleted: (configurationId: string) => void;
|
||||
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
|
||||
onConfigurationTested: (configuration: LighthouseV2Configuration) => void;
|
||||
@@ -87,7 +65,6 @@ export function LighthouseV2ConfigurationForm({
|
||||
defaultValues: getFormDefaults(configuration),
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const businessContext = form.watch("businessContext");
|
||||
const status = getConnectionStatus(configuration);
|
||||
|
||||
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
|
||||
@@ -102,8 +79,6 @@ export function LighthouseV2ConfigurationForm({
|
||||
|
||||
const basePayload = {
|
||||
baseUrl: trimToNullable(values.baseUrl),
|
||||
defaultModel: trimToNullable(values.defaultModel),
|
||||
businessContext: values.businessContext,
|
||||
};
|
||||
|
||||
const result = configuration
|
||||
@@ -177,7 +152,7 @@ export function LighthouseV2ConfigurationForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
<section className="flex h-full w-full min-w-0 flex-col">
|
||||
<div className="border-border-neutral-secondary flex flex-col gap-4 border-b px-4 py-6 md:flex-row md:items-start md:justify-between md:px-5">
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
|
||||
@@ -218,97 +193,29 @@ export function LighthouseV2ConfigurationForm({
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-0"
|
||||
className="flex h-full min-h-0 w-full flex-1 flex-col"
|
||||
onSubmit={form.handleSubmit(handleSave)}
|
||||
noValidate
|
||||
>
|
||||
<ConfigurationSection
|
||||
icon={<KeyRound className="size-4" />}
|
||||
title="Credentials"
|
||||
description={
|
||||
configuration
|
||||
? "Leave blank to keep existing credentials."
|
||||
: "Credentials are required for new configurations."
|
||||
}
|
||||
>
|
||||
<CredentialFields
|
||||
errors={form.formState.errors}
|
||||
provider={providerType}
|
||||
register={form.register}
|
||||
/>
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
icon={<Sparkles className="size-4" />}
|
||||
title="Default model"
|
||||
description="Model used when chat does not override provider/model for a turn."
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="defaultModel"
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-model">
|
||||
Default model
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
allowDeselect
|
||||
>
|
||||
<SelectTrigger id="lighthouse-v2-model">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent width="wide">
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
icon={<Bot className="size-4" />}
|
||||
title="Business context"
|
||||
description="Short operational context Lighthouse AI should consider while answering."
|
||||
>
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel htmlFor="lighthouse-v2-business-context">
|
||||
Business context
|
||||
</FieldLabel>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
businessContext.length > BUSINESS_CONTEXT_LIMIT
|
||||
? "text-text-error-primary"
|
||||
: "text-text-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
{businessContext.length}/{BUSINESS_CONTEXT_LIMIT}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="lighthouse-v2-business-context"
|
||||
textareaSize="lg"
|
||||
aria-invalid={Boolean(form.formState.errors.businessContext)}
|
||||
placeholder="Example: production AWS accounts, PCI workloads, EU data residency, critical internet-facing services..."
|
||||
{...form.register("businessContext")}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ConfigurationSection
|
||||
icon={<KeyRound className="size-4" />}
|
||||
title="Credentials"
|
||||
description={
|
||||
configuration
|
||||
? "Leave blank to keep existing credentials."
|
||||
: "Credentials are required for new configurations."
|
||||
}
|
||||
>
|
||||
<CredentialFields
|
||||
errors={form.formState.errors}
|
||||
provider={providerType}
|
||||
register={form.register}
|
||||
/>
|
||||
{form.formState.errors.businessContext?.message && (
|
||||
<FieldError>
|
||||
{form.formState.errors.businessContext.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
</ConfigurationSection>
|
||||
</ConfigurationSection>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-6 sm:flex-row sm:items-center sm:justify-between md:px-5">
|
||||
<div className="border-border-neutral-secondary mt-auto flex flex-col gap-4 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between md:px-5">
|
||||
<div className="text-text-neutral-secondary text-sm">
|
||||
{configuration
|
||||
? "Saving updates may change chat behavior immediately."
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
FEEDBACK_VARIANT,
|
||||
type FeedbackState,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/config";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
|
||||
|
||||
export function ConfigFeedbackAlert({
|
||||
feedback,
|
||||
onClose,
|
||||
}: {
|
||||
feedback: FeedbackState;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const Icon =
|
||||
feedback.variant === FEEDBACK_VARIANT.ERROR
|
||||
? AlertCircle
|
||||
: feedback.variant === FEEDBACK_VARIANT.SUCCESS
|
||||
? CheckCircle2
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<Alert variant={feedback.variant} onClose={onClose}>
|
||||
<Icon className="size-4" />
|
||||
<AlertTitle>{feedback.title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{feedback.description && <p>{feedback.description}</p>}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
+71
-106
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
LighthouseV2Configuration,
|
||||
LighthouseV2SupportedModel,
|
||||
LighthouseV2SupportedProvider,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
@@ -15,22 +14,33 @@ const {
|
||||
deleteConfigurationMock,
|
||||
testConnectionMock,
|
||||
updateConfigurationMock,
|
||||
updateTenantConfigurationMock,
|
||||
toastMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createConfigurationMock: vi.fn(),
|
||||
deleteConfigurationMock: vi.fn(),
|
||||
testConnectionMock: vi.fn(),
|
||||
updateConfigurationMock: vi.fn(),
|
||||
updateTenantConfigurationMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Action feedback is delivered through toasts (rendered by the layout Toaster),
|
||||
// so we assert the dispatched toast rather than in-page banner text.
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: toastMock, dismiss: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
createLighthouseV2Configuration: createConfigurationMock,
|
||||
deleteLighthouseV2Configuration: deleteConfigurationMock,
|
||||
testLighthouseV2ConfigurationConnection: testConnectionMock,
|
||||
updateLighthouseV2Configuration: updateConfigurationMock,
|
||||
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
|
||||
}));
|
||||
|
||||
const providers: LighthouseV2SupportedProvider[] = [
|
||||
@@ -39,31 +49,12 @@ const providers: LighthouseV2SupportedProvider[] = [
|
||||
{ id: "openai-compatible", name: "OpenAI-compatible" },
|
||||
];
|
||||
|
||||
const modelsByProvider = {
|
||||
openai: [
|
||||
model("gpt-5.1", {
|
||||
supportsFunctionCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
}),
|
||||
],
|
||||
bedrock: [
|
||||
model("anthropic.claude-4", {
|
||||
supportsFunctionCalling: true,
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
}),
|
||||
],
|
||||
"openai-compatible": [model("llama-3.3")],
|
||||
};
|
||||
|
||||
const configurations: LighthouseV2Configuration[] = [
|
||||
{
|
||||
id: "config-openai",
|
||||
providerType: "openai",
|
||||
baseUrl: null,
|
||||
defaultModel: "gpt-5.1",
|
||||
businessContext: "Production account",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||
insertedAt: "2026-06-24T09:00:00Z",
|
||||
@@ -74,7 +65,6 @@ const configurations: LighthouseV2Configuration[] = [
|
||||
providerType: "bedrock",
|
||||
baseUrl: null,
|
||||
defaultModel: "anthropic.claude-4",
|
||||
businessContext: "AWS landing zone",
|
||||
connected: false,
|
||||
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
||||
insertedAt: "2026-06-23T09:00:00Z",
|
||||
@@ -88,6 +78,16 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
deleteConfigurationMock.mockReset();
|
||||
testConnectionMock.mockReset();
|
||||
updateConfigurationMock.mockReset();
|
||||
updateTenantConfigurationMock.mockReset();
|
||||
toastMock.mockReset();
|
||||
updateTenantConfigurationMock.mockResolvedValue({
|
||||
data: {
|
||||
id: "tenant-config-1",
|
||||
businessContext: "Updated business context",
|
||||
defaultProvider: "",
|
||||
defaultModels: {},
|
||||
},
|
||||
});
|
||||
|
||||
createConfigurationMock.mockResolvedValue({ data: configurations[0] });
|
||||
deleteConfigurationMock.mockResolvedValue({ data: true });
|
||||
@@ -120,12 +120,7 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
);
|
||||
|
||||
expect(settingsCard).toHaveAttribute("data-slot", "card");
|
||||
expect(settingsCard).toHaveClass(
|
||||
"min-h-[calc(100dvh-6.5rem)]",
|
||||
"w-full",
|
||||
"gap-0",
|
||||
"overflow-hidden",
|
||||
);
|
||||
expect(settingsCard).toHaveClass("w-full", "gap-0", "overflow-hidden");
|
||||
expect(settingsCard).not.toHaveClass("mx-auto", "max-w-7xl");
|
||||
expect(settingsSeparator).toHaveClass(
|
||||
"border-t",
|
||||
@@ -149,50 +144,50 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads provider-specific form values when switching provider", async () => {
|
||||
it("renders a single shared business context, not a per-provider default model", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
// Then: business context is tenant-wide, so there is exactly one editor
|
||||
expect(
|
||||
screen.getAllByRole("textbox", { name: /Business context/i }),
|
||||
).toHaveLength(1);
|
||||
|
||||
// When switching providers, no per-provider model/context field appears
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
).toHaveValue("AWS landing zone");
|
||||
screen.queryByRole("combobox", { name: "Default model" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Default model")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Default model" }),
|
||||
).toHaveTextContent("anthropic.claude-4");
|
||||
screen.getAllByRole("textbox", { name: /Business context/i }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updates an existing configuration without sending blank credentials", async () => {
|
||||
it("updates an existing configuration without sending blank credentials or model defaults", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
updateConfigurationMock.mockResolvedValue({
|
||||
data: {
|
||||
...configurations[0],
|
||||
businessContext: "Updated context",
|
||||
},
|
||||
});
|
||||
updateConfigurationMock.mockResolvedValue({ data: configurations[0] });
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
await user.clear(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
// When: save the active provider without touching credentials
|
||||
await user.click(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Lighthouse AI settings" }),
|
||||
).getByRole("button", { name: /^Save$/i }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
"Updated context",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(updateConfigurationMock).toHaveBeenCalledWith(
|
||||
"config-openai",
|
||||
expect.not.objectContaining({ credentials: expect.anything() }),
|
||||
),
|
||||
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[0]).toBe("config-openai");
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
|
||||
"credentials",
|
||||
);
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
|
||||
"defaultModel",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -204,7 +199,6 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
providerType: "openai-compatible",
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
defaultModel: "llama-3.3",
|
||||
businessContext: "Private model",
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
insertedAt: "2026-06-24T10:00:00Z",
|
||||
@@ -222,22 +216,23 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
screen.getByLabelText("Base URL"),
|
||||
"https://llm.example.com/v1",
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Business context/i }),
|
||||
"Private model",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(createConfigurationMock).toHaveBeenCalledWith({
|
||||
await waitFor(() => expect(createConfigurationMock).toHaveBeenCalled());
|
||||
expect(createConfigurationMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
providerType: "openai-compatible",
|
||||
credentials: { api_key: "provider-key" },
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
defaultModel: null,
|
||||
businessContext: "Private model",
|
||||
}),
|
||||
);
|
||||
expect(createConfigurationMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"defaultModel",
|
||||
);
|
||||
expect(createConfigurationMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"businessContext",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks OpenAI-compatible save when base URL is missing", async () => {
|
||||
@@ -275,32 +270,6 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the business context counter and blocks values over 1000 characters", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
const businessContext = screen.getByRole("textbox", {
|
||||
name: /Business context/i,
|
||||
});
|
||||
await user.clear(businessContext);
|
||||
// Paste the whole string in one event instead of 1001 keystrokes, which
|
||||
// re-renders the watched form on every character and times out under load.
|
||||
await user.click(businessContext);
|
||||
await user.paste("a".repeat(1001));
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("1001/1000")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Business context cannot exceed 1000 characters.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(updateConfigurationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tests the connection and reports the resulting status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -316,7 +285,14 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
await waitFor(() =>
|
||||
expect(testConnectionMock).toHaveBeenCalledWith("config-openai"),
|
||||
);
|
||||
expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Connection failed.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Refresh status/i }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -337,7 +313,11 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
await waitFor(() =>
|
||||
expect(deleteConfigurationMock).toHaveBeenCalledWith("config-openai"),
|
||||
);
|
||||
expect(screen.getByText("Configuration removed.")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Configuration removed." }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,23 +328,8 @@ function renderPage(
|
||||
<LighthouseV2ConfigPage
|
||||
configurations={props?.configurations ?? configurations}
|
||||
providers={props?.providers ?? providers}
|
||||
modelsByProvider={props?.modelsByProvider ?? modelsByProvider}
|
||||
tenantConfiguration={props?.tenantConfiguration}
|
||||
error={props?.error}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function model(
|
||||
id: string,
|
||||
overrides: Partial<LighthouseV2SupportedModel> = {},
|
||||
): LighthouseV2SupportedModel {
|
||||
return {
|
||||
id,
|
||||
maxInputTokens: null,
|
||||
maxOutputTokens: null,
|
||||
supportsFunctionCalling: null,
|
||||
supportsVision: null,
|
||||
supportsReasoning: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,45 +9,57 @@ import {
|
||||
import {
|
||||
type LighthouseV2Configuration,
|
||||
type LighthouseV2ProviderType,
|
||||
type LighthouseV2SupportedModel,
|
||||
type LighthouseV2SupportedProvider,
|
||||
type LighthouseV2TenantConfiguration,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
|
||||
import { LighthouseV2BusinessContextForm } from "./business-context-form";
|
||||
import { LighthouseV2ConfigurationForm } from "./configuration-form";
|
||||
import { LighthouseV2EmptyState } from "./empty-state";
|
||||
import { ConfigFeedbackAlert } from "./feedback-alert";
|
||||
import { LighthouseV2ProviderRail } from "./provider-rail";
|
||||
|
||||
interface LighthouseV2ConfigPageProps {
|
||||
configurations: LighthouseV2Configuration[];
|
||||
providers: LighthouseV2SupportedProvider[];
|
||||
modelsByProvider: Record<
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel[]
|
||||
>;
|
||||
tenantConfiguration?: LighthouseV2TenantConfiguration;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function LighthouseV2ConfigPage({
|
||||
configurations,
|
||||
providers,
|
||||
modelsByProvider,
|
||||
tenantConfiguration,
|
||||
error,
|
||||
}: LighthouseV2ConfigPageProps) {
|
||||
const { toast } = useToast();
|
||||
const [localConfigurations, setLocalConfigurations] =
|
||||
useState(configurations);
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<LighthouseV2ProviderType>(providers[0]?.id ?? "openai");
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(
|
||||
error
|
||||
? {
|
||||
title: "Configuration unavailable",
|
||||
description: error,
|
||||
variant: FEEDBACK_VARIANT.ERROR,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const showFeedback = (feedback: FeedbackState) => {
|
||||
toast({
|
||||
title: feedback.title,
|
||||
description: feedback.description,
|
||||
variant:
|
||||
feedback.variant === FEEDBACK_VARIANT.ERROR ? "destructive" : "default",
|
||||
});
|
||||
};
|
||||
|
||||
// Surface a load-time error (failed fetch) once, since it is not tied to a
|
||||
// user interaction that could dispatch the toast itself.
|
||||
useMountEffect(() => {
|
||||
if (error) {
|
||||
showFeedback({
|
||||
title: "Configuration unavailable",
|
||||
description: error,
|
||||
variant: FEEDBACK_VARIANT.ERROR,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const selectedConfig = localConfigurations.find(
|
||||
(config) => config.providerType === selectedProvider,
|
||||
@@ -55,11 +67,6 @@ export function LighthouseV2ConfigPage({
|
||||
const selectedProviderDefinition =
|
||||
providers.find((provider) => provider.id === selectedProvider) ??
|
||||
providers[0];
|
||||
const selectedModels =
|
||||
selectedProviderDefinition &&
|
||||
modelsByProvider[selectedProviderDefinition.id]
|
||||
? modelsByProvider[selectedProviderDefinition.id]
|
||||
: [];
|
||||
|
||||
const upsertConfiguration = (configuration: LighthouseV2Configuration) => {
|
||||
setLocalConfigurations((current) => [
|
||||
@@ -73,7 +80,7 @@ export function LighthouseV2ConfigPage({
|
||||
) => {
|
||||
upsertConfiguration(configuration);
|
||||
setSelectedProvider(configuration.providerType);
|
||||
setFeedback({
|
||||
showFeedback({
|
||||
title: "Configuration saved.",
|
||||
description:
|
||||
"Lighthouse AI can use this provider after it tests cleanly.",
|
||||
@@ -85,7 +92,7 @@ export function LighthouseV2ConfigPage({
|
||||
configuration: LighthouseV2Configuration,
|
||||
) => {
|
||||
upsertConfiguration(configuration);
|
||||
setFeedback(
|
||||
showFeedback(
|
||||
configuration.connected
|
||||
? {
|
||||
title: "Connection successful.",
|
||||
@@ -105,7 +112,7 @@ export function LighthouseV2ConfigPage({
|
||||
setLocalConfigurations((current) =>
|
||||
current.filter((config) => config.id !== configurationId),
|
||||
);
|
||||
setFeedback({
|
||||
showFeedback({
|
||||
title: "Configuration removed.",
|
||||
description: "This provider is no longer available for Lighthouse AI.",
|
||||
variant: FEEDBACK_VARIANT.INFO,
|
||||
@@ -122,27 +129,19 @@ export function LighthouseV2ConfigPage({
|
||||
padding="none"
|
||||
role="region"
|
||||
aria-label="Lighthouse AI settings"
|
||||
className="min-h-[calc(100dvh-6.5rem)] w-full gap-0 overflow-hidden"
|
||||
className="w-full gap-0 overflow-hidden"
|
||||
>
|
||||
{feedback && (
|
||||
<div className="border-border-neutral-secondary border-b px-4 py-4 md:px-5">
|
||||
<ConfigFeedbackAlert
|
||||
feedback={feedback}
|
||||
onClose={() => setFeedback(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<LighthouseV2BusinessContextForm
|
||||
initialBusinessContext={tenantConfiguration?.businessContext ?? ""}
|
||||
/>
|
||||
|
||||
<div className="grid min-h-0 gap-0 xl:grid-cols-[320px_auto_minmax(0,1fr)]">
|
||||
<div className="grid min-h-0 flex-1 gap-0 xl:grid-cols-[320px_auto_minmax(0,1fr)]">
|
||||
<div className="min-w-0 p-4 md:p-5">
|
||||
<LighthouseV2ProviderRail
|
||||
configurations={localConfigurations}
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={(provider) => {
|
||||
setSelectedProvider(provider);
|
||||
setFeedback(null);
|
||||
}}
|
||||
onSelectProvider={setSelectedProvider}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,16 +151,17 @@ export function LighthouseV2ConfigPage({
|
||||
className="border-border-neutral-secondary border-t xl:border-t-0 xl:border-l"
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-h-0 w-full min-w-0">
|
||||
<LighthouseV2ConfigurationForm
|
||||
key={selectedProvider}
|
||||
configuration={selectedConfig}
|
||||
models={selectedModels}
|
||||
provider={selectedProviderDefinition}
|
||||
onConfigurationSaved={handleConfigurationSaved}
|
||||
onConfigurationDeleted={handleConfigurationDeleted}
|
||||
onConfigurationTested={handleConfigurationTested}
|
||||
onFeedback={setFeedback}
|
||||
onFeedback={(feedback) => {
|
||||
if (feedback) showFeedback(feedback);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,9 +67,6 @@ export function LighthouseV2ProviderRail({
|
||||
</span>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary mt-1 truncate text-xs">
|
||||
{config?.defaultModel || "No default model"}
|
||||
</p>
|
||||
<p className="text-text-neutral-tertiary mt-1 text-xs">
|
||||
{formatLastChecked(config?.connectionLastCheckedAt)}
|
||||
</p>
|
||||
|
||||
@@ -39,10 +39,6 @@ const lighthouseV2ConfigFormSchemaBase = z.object({
|
||||
awsSecretAccessKey: z.string(),
|
||||
awsRegionName: z.string(),
|
||||
baseUrl: z.string(),
|
||||
defaultModel: z.string(),
|
||||
businessContext: z.string().max(BUSINESS_CONTEXT_LIMIT, {
|
||||
error: "Business context cannot exceed 1000 characters.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type LighthouseV2ConfigFormValues = z.infer<
|
||||
@@ -55,8 +51,6 @@ export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
|
||||
awsSecretAccessKey: "",
|
||||
awsRegionName: "",
|
||||
baseUrl: "",
|
||||
defaultModel: "",
|
||||
businessContext: "",
|
||||
};
|
||||
|
||||
export function getFormDefaults(
|
||||
@@ -65,8 +59,6 @@ export function getFormDefaults(
|
||||
return {
|
||||
...EMPTY_FORM_VALUES,
|
||||
baseUrl: configuration?.baseUrl ?? "",
|
||||
defaultModel: configuration?.defaultModel ?? "",
|
||||
businessContext: configuration?.businessContext ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildLighthouseV2ModelSelectionValue,
|
||||
parseLighthouseV2ModelSelectionValue,
|
||||
} from "./model-selection";
|
||||
|
||||
describe("model-selection value codec", () => {
|
||||
it("round-trips a provider and model id", () => {
|
||||
// Given
|
||||
const value = buildLighthouseV2ModelSelectionValue("openai", "gpt-5.1");
|
||||
|
||||
// When
|
||||
const selection = parseLighthouseV2ModelSelectionValue(value);
|
||||
|
||||
// Then
|
||||
expect(value).toBe("openai:gpt-5.1");
|
||||
expect(selection).toEqual({ providerType: "openai", modelId: "gpt-5.1" });
|
||||
});
|
||||
|
||||
it("keeps colons inside Bedrock model ids by splitting on the first colon only", () => {
|
||||
// Given
|
||||
const value = buildLighthouseV2ModelSelectionValue(
|
||||
"bedrock",
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
);
|
||||
|
||||
// When
|
||||
const selection = parseLighthouseV2ModelSelectionValue(value);
|
||||
|
||||
// Then
|
||||
expect(selection).toEqual({
|
||||
providerType: "bedrock",
|
||||
modelId: "anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects values without a known provider or model id", () => {
|
||||
// Then
|
||||
expect(parseLighthouseV2ModelSelectionValue("")).toBeNull();
|
||||
expect(parseLighthouseV2ModelSelectionValue("gpt-5.1")).toBeNull();
|
||||
expect(parseLighthouseV2ModelSelectionValue(":gpt-5.1")).toBeNull();
|
||||
expect(parseLighthouseV2ModelSelectionValue("unknown:gpt-5.1")).toBeNull();
|
||||
expect(parseLighthouseV2ModelSelectionValue("openai:")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
LIGHTHOUSE_V2_PROVIDER_TYPE,
|
||||
type LighthouseV2ProviderType,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
export interface LighthouseV2ModelSelection {
|
||||
providerType: LighthouseV2ProviderType;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
// Encodes a provider + model into a single combobox option value. The value is
|
||||
// kept human-readable (no encoding) so the combobox search matches the model id
|
||||
// the user types. Bedrock model ids can contain ":" (e.g. "...-v1:0"), so the
|
||||
// parser splits on the FIRST ":" only.
|
||||
export function buildLighthouseV2ModelSelectionValue(
|
||||
providerType: LighthouseV2ProviderType,
|
||||
modelId: string,
|
||||
) {
|
||||
return `${providerType}:${modelId}`;
|
||||
}
|
||||
|
||||
export function parseLighthouseV2ModelSelectionValue(
|
||||
value: string,
|
||||
): LighthouseV2ModelSelection | null {
|
||||
const separatorIndex = value.indexOf(":");
|
||||
if (separatorIndex <= 0) return null;
|
||||
|
||||
const providerType = value.slice(0, separatorIndex);
|
||||
if (!isLighthouseV2ProviderType(providerType)) return null;
|
||||
|
||||
const modelId = value.slice(separatorIndex + 1);
|
||||
if (!modelId) return null;
|
||||
|
||||
return { providerType, modelId };
|
||||
}
|
||||
|
||||
function isLighthouseV2ProviderType(
|
||||
value: string,
|
||||
): value is LighthouseV2ProviderType {
|
||||
return (
|
||||
value === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
|
||||
value === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK ||
|
||||
value === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export interface LighthouseV2Configuration {
|
||||
providerType: LighthouseV2ProviderType;
|
||||
baseUrl: string | null;
|
||||
defaultModel: string | null;
|
||||
businessContext: string;
|
||||
connected: boolean | null;
|
||||
connectionLastCheckedAt: string | null;
|
||||
insertedAt: string;
|
||||
@@ -48,15 +47,24 @@ export interface LighthouseV2ConfigurationInput {
|
||||
providerType: LighthouseV2ProviderType;
|
||||
credentials: LighthouseV2Credentials;
|
||||
baseUrl?: string | null;
|
||||
defaultModel?: string | null;
|
||||
businessContext?: string;
|
||||
}
|
||||
|
||||
export interface LighthouseV2ConfigurationUpdateInput {
|
||||
credentials?: LighthouseV2Credentials;
|
||||
baseUrl?: string | null;
|
||||
defaultModel?: string | null;
|
||||
}
|
||||
|
||||
export interface LighthouseV2TenantConfiguration {
|
||||
id: string;
|
||||
businessContext: string;
|
||||
defaultProvider: LighthouseV2ProviderType | "";
|
||||
defaultModels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface LighthouseV2TenantConfigurationUpdateInput {
|
||||
businessContext?: string;
|
||||
defaultProvider?: LighthouseV2ProviderType | "";
|
||||
defaultModels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface LighthouseV2SupportedProvider {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getLighthouseV2Messages,
|
||||
getLighthouseV2Session,
|
||||
getLighthouseV2SupportedModels,
|
||||
getLighthouseV2TenantConfiguration,
|
||||
} from "@/app/(prowler)/lighthouse/_actions";
|
||||
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
|
||||
import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_components/navigation";
|
||||
@@ -36,7 +37,9 @@ export default async function AIChatbot({
|
||||
typeof params.session === "string" ? params.session : undefined;
|
||||
|
||||
if (isCloud()) {
|
||||
const configurationsResult = await getLighthouseV2Configurations();
|
||||
const [configurationsResult, tenantConfigurationResult] = await Promise.all(
|
||||
[getLighthouseV2Configurations(), getLighthouseV2TenantConfiguration()],
|
||||
);
|
||||
const configurations =
|
||||
"data" in configurationsResult ? configurationsResult.data : [];
|
||||
const connectedConfigurations = configurations.filter(
|
||||
@@ -89,6 +92,11 @@ export default async function AIChatbot({
|
||||
key={chatRouteKey}
|
||||
configurations={configurations}
|
||||
modelsByProvider={modelsByProvider}
|
||||
tenantConfiguration={
|
||||
"data" in tenantConfigurationResult
|
||||
? tenantConfigurationResult.data
|
||||
: undefined
|
||||
}
|
||||
initialSessionId={activeSessionId}
|
||||
initialMessages={
|
||||
"data" in initialMessages ? initialMessages.data : []
|
||||
|
||||
@@ -2,14 +2,10 @@ import { Spacer } from "@heroui/spacer";
|
||||
|
||||
import {
|
||||
getLighthouseV2Configurations,
|
||||
getLighthouseV2SupportedModels,
|
||||
getLighthouseV2SupportedProviders,
|
||||
getLighthouseV2TenantConfiguration,
|
||||
} from "@/app/(prowler)/lighthouse/_actions";
|
||||
import { LighthouseV2ConfigPage } from "@/app/(prowler)/lighthouse/_components/config";
|
||||
import type {
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import {
|
||||
LighthouseSettings,
|
||||
LLMProvidersTable,
|
||||
@@ -21,28 +17,14 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LighthouseSettingsPage() {
|
||||
if (isCloud()) {
|
||||
const [configurationsResult, providersResult] = await Promise.all([
|
||||
getLighthouseV2Configurations(),
|
||||
getLighthouseV2SupportedProviders(),
|
||||
]);
|
||||
const [configurationsResult, providersResult, tenantConfigurationResult] =
|
||||
await Promise.all([
|
||||
getLighthouseV2Configurations(),
|
||||
getLighthouseV2SupportedProviders(),
|
||||
getLighthouseV2TenantConfiguration(),
|
||||
]);
|
||||
|
||||
const providers = "data" in providersResult ? providersResult.data : [];
|
||||
const modelsEntries = await Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
const result = await getLighthouseV2SupportedModels(provider.id);
|
||||
return [
|
||||
provider.id,
|
||||
"data" in result ? result.data : [],
|
||||
] as const satisfies readonly [
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel[],
|
||||
];
|
||||
}),
|
||||
);
|
||||
const modelsByProvider = Object.fromEntries(modelsEntries) as Record<
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2SupportedModel[]
|
||||
>;
|
||||
const error =
|
||||
"error" in configurationsResult
|
||||
? configurationsResult.error
|
||||
@@ -57,7 +39,11 @@ export default async function LighthouseSettingsPage() {
|
||||
"data" in configurationsResult ? configurationsResult.data : []
|
||||
}
|
||||
providers={providers}
|
||||
modelsByProvider={modelsByProvider}
|
||||
tenantConfiguration={
|
||||
"data" in tenantConfigurationResult
|
||||
? tenantConfigurationResult.data
|
||||
: undefined
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
</ContentLayout>
|
||||
|
||||
Reference in New Issue
Block a user