refactor(ui): streamline Lighthouse config and poll connection test

This commit is contained in:
alejandrobailo
2026-06-25 20:30:37 +02:00
parent f518fcd911
commit 5323b3475e
11 changed files with 98 additions and 216 deletions
@@ -1,5 +1,6 @@
"use server";
import { pollTaskUntilSettled } from "@/actions/task/poll";
import type {
LighthouseV2Configuration,
LighthouseV2ConfigurationInput,
@@ -101,16 +102,51 @@ export async function deleteLighthouseV2Configuration(
);
}
// 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.
export async function testLighthouseV2ConfigurationConnection(
configId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Task>> {
return mutateSingle(
`/lighthouse/config/${encodeURIComponent(configId)}/connection`,
{ method: "POST" },
mapLighthouseV2Task,
"/lighthouse/config",
false,
);
): Promise<LighthouseV2ActionResult<LighthouseV2Configuration>> {
try {
const response = await fetch(
buildApiUrl(
`/lighthouse/config/${encodeURIComponent(configId)}/connection`,
),
{ method: "POST", headers: await getAuthHeaders({ contentType: false }) },
);
const document = (await handleApiResponse(
response,
)) as JsonApiDocument<TaskResource>;
if (isErrorDocument(document) || !document.data) {
return toErrorResult(document);
}
const settled = await pollTaskUntilSettled(
mapLighthouseV2Task(document.data).id,
{
maxAttempts: 20,
delayMs: 3000,
},
);
if (!settled.ok) {
return { error: settled.error || "Connection test timed out." };
}
const configurations = await getLighthouseV2Configurations();
if ("error" in configurations) {
return configurations;
}
const updated = configurations.data.find(
(config) => config.id === configId,
);
if (!updated) {
return { error: "Configuration not found after connection test." };
}
return { data: updated };
} catch (error) {
return handleApiError(error);
}
}
export async function getLighthouseV2SupportedProviders(): Promise<
@@ -6,13 +6,10 @@ import {
KeyRound,
Loader2,
PlugZap,
RefreshCw,
Save,
ShieldCheck,
Sparkles,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -34,6 +31,7 @@ import {
type LighthouseV2ConfigFormValues,
trimToNullable,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { formatLastChecked } from "@/app/(prowler)/lighthouse/_lib/format";
import {
type LighthouseV2Configuration,
type LighthouseV2ConfigurationInput,
@@ -55,9 +53,7 @@ import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
import { ConfigurationSection } from "./configuration-section";
import { ConnectionStatusPanel } from "./connection-status-panel";
import { CredentialFields } from "./credential-fields";
import { ModelDetails } from "./model-details";
import { ProviderIcon } from "./provider-icon";
import { StatusBadge } from "./status-badge";
@@ -66,6 +62,7 @@ export function LighthouseV2ConfigurationForm({
models,
onConfigurationDeleted,
onConfigurationSaved,
onConfigurationTested,
onFeedback,
provider,
}: {
@@ -73,10 +70,10 @@ export function LighthouseV2ConfigurationForm({
models: LighthouseV2SupportedModel[];
onConfigurationDeleted: (configurationId: string) => void;
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
onConfigurationTested: (configuration: LighthouseV2Configuration) => void;
onFeedback: (feedback: FeedbackState | null) => void;
provider: LighthouseV2SupportedProvider;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -91,10 +88,6 @@ export function LighthouseV2ConfigurationForm({
mode: "onSubmit",
});
const businessContext = form.watch("businessContext");
const selectedModel = form.watch("defaultModel");
const selectedModelDetails = models.find(
(model) => model.id === selectedModel,
);
const status = getConnectionStatus(configuration);
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
@@ -152,20 +145,14 @@ export function LighthouseV2ConfigurationForm({
if ("error" in result) {
onFeedback({
title: "Connection check failed to start",
title: "Connection check failed",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
onFeedback({
title: "Connection check started.",
description:
"The backend is validating this provider. Refresh status when the task finishes.",
variant: FEEDBACK_VARIANT.INFO,
showRefreshStatus: true,
});
onConfigurationTested(result.data);
};
const handleDelete = async () => {
@@ -191,7 +178,7 @@ export function LighthouseV2ConfigurationForm({
return (
<section className="min-w-0">
<div className="border-border-neutral-secondary flex flex-col gap-4 border-b px-4 py-4 md:flex-row md:items-start md:justify-between md:px-5">
<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">
<ProviderIcon
@@ -205,6 +192,9 @@ export function LighthouseV2ConfigurationForm({
{provider.name}
</h3>
<StatusBadge status={status} />
<span className="text-text-neutral-tertiary text-xs">
{formatLastChecked(configuration?.connectionLastCheckedAt)}
</span>
</div>
<p className="text-text-neutral-secondary mt-1 max-w-2xl text-sm">
{configuration
@@ -222,15 +212,7 @@ export function LighthouseV2ConfigurationForm({
disabled={!configuration || testing}
>
{testing ? <Loader2 className="animate-spin" /> : <PlugZap />}
Test connection
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.refresh()}
>
<RefreshCw />
Refresh status
{testing ? "Testing connection…" : "Test connection"}
</Button>
</div>
</div>
@@ -240,17 +222,6 @@ export function LighthouseV2ConfigurationForm({
onSubmit={form.handleSubmit(handleSave)}
noValidate
>
<ConfigurationSection
icon={<ShieldCheck className="size-4" />}
title="Connection"
description="Current backend check result for this provider."
>
<ConnectionStatusPanel
configuration={configuration}
status={status}
/>
</ConfigurationSection>
<ConfigurationSection
icon={<KeyRound className="size-4" />}
title="Credentials"
@@ -262,7 +233,6 @@ export function LighthouseV2ConfigurationForm({
>
<CredentialFields
errors={form.formState.errors}
hasConfiguration={hasConfiguration}
provider={providerType}
register={form.register}
/>
@@ -300,7 +270,6 @@ export function LighthouseV2ConfigurationForm({
</Field>
)}
/>
<ModelDetails model={selectedModelDetails} />
</ConfigurationSection>
<ConfigurationSection
@@ -339,7 +308,7 @@ export function LighthouseV2ConfigurationForm({
</Field>
</ConfigurationSection>
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between md:px-5">
<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="text-text-neutral-secondary text-sm">
{configuration
? "Saving updates may change chat behavior immediately."
@@ -12,7 +12,7 @@ export function ConfigurationSection({
title: string;
}) {
return (
<section className="border-border-neutral-secondary grid gap-4 border-b px-4 py-5 md:grid-cols-[220px_minmax(0,1fr)] md:px-5">
<section className="border-border-neutral-secondary grid gap-6 border-b px-4 py-8 md:grid-cols-[220px_minmax(0,1fr)] md:px-5">
<div className="flex gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-8 shrink-0 items-center justify-center rounded-[8px] border">
{icon}
@@ -1,44 +0,0 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import {
CONNECTION_STATUS,
type ConnectionStatus,
getAlertVariant,
getConnectionStatusLabel,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { formatLastChecked } from "@/app/(prowler)/lighthouse/_lib/format";
import { type LighthouseV2Configuration } from "@/app/(prowler)/lighthouse/_types";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
export function ConnectionStatusPanel({
configuration,
status,
}: {
configuration?: LighthouseV2Configuration;
status: ConnectionStatus;
}) {
const statusText = getConnectionStatusLabel(status);
const description =
status === CONNECTION_STATUS.CONNECTED
? "Lighthouse can send messages with this provider."
: status === CONNECTION_STATUS.FAILED
? "Connection failed. Review credentials and run another test."
: "Connection has not been tested yet.";
return (
<Alert variant={getAlertVariant(status)}>
{status === CONNECTION_STATUS.CONNECTED ? (
<CheckCircle2 className="size-4" />
) : status === CONNECTION_STATUS.FAILED ? (
<AlertCircle className="size-4" />
) : (
<CircleDashed className="size-4" />
)}
<AlertTitle>{statusText}</AlertTitle>
<AlertDescription>
<p>{description}</p>
<p>{formatLastChecked(configuration?.connectionLastCheckedAt)}</p>
</AlertDescription>
</Alert>
);
}
@@ -10,14 +10,12 @@ import { Input } from "@/components/shadcn/input/input";
export function CredentialFields({
errors,
hasConfiguration,
provider,
register,
}: {
errors: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["formState"]["errors"];
hasConfiguration: boolean;
provider: LighthouseV2ProviderType;
register: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
@@ -25,12 +23,6 @@ export function CredentialFields({
}) {
return (
<div className="grid gap-4">
{hasConfiguration && (
<p className="text-text-neutral-secondary text-sm">
Leave blank to keep existing credentials.
</p>
)}
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
<Field>
@@ -1,27 +1,24 @@
import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react";
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";
import { Button } from "@/components/shadcn/button/button";
export function ConfigFeedbackAlert({
feedback,
onClose,
onRefreshStatus,
}: {
feedback: FeedbackState;
onClose: () => void;
onRefreshStatus: () => void;
}) {
const Icon =
feedback.variant === FEEDBACK_VARIANT.ERROR
? AlertCircle
: feedback.variant === FEEDBACK_VARIANT.SUCCESS
? CheckCircle2
: RefreshCw;
: Info;
return (
<Alert variant={feedback.variant} onClose={onClose}>
@@ -29,18 +26,6 @@ export function ConfigFeedbackAlert({
<AlertTitle>{feedback.title}</AlertTitle>
<AlertDescription>
{feedback.description && <p>{feedback.description}</p>}
{feedback.showRefreshStatus && (
<Button
type="button"
variant="link"
size="link-sm"
className="h-auto p-0"
onClick={onRefreshStatus}
>
<RefreshCw className="size-3.5" />
Refresh status
</Button>
)}
</AlertDescription>
</Alert>
);
@@ -13,21 +13,17 @@ import { LighthouseV2ConfigPage } from "./lighthouse-v2-config-page";
const {
createConfigurationMock,
deleteConfigurationMock,
refreshMock,
testConnectionMock,
updateConfigurationMock,
} = vi.hoisted(() => ({
createConfigurationMock: vi.fn(),
deleteConfigurationMock: vi.fn(),
refreshMock: vi.fn(),
testConnectionMock: vi.fn(),
updateConfigurationMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: refreshMock,
}),
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
@@ -90,20 +86,15 @@ describe("LighthouseV2ConfigPage", () => {
beforeEach(() => {
createConfigurationMock.mockReset();
deleteConfigurationMock.mockReset();
refreshMock.mockReset();
testConnectionMock.mockReset();
updateConfigurationMock.mockReset();
createConfigurationMock.mockResolvedValue({ data: configurations[0] });
deleteConfigurationMock.mockResolvedValue({ data: true });
// The action polls the task internally and resolves with the re-fetched
// configuration carrying the authoritative connection status.
testConnectionMock.mockResolvedValue({
data: {
id: "task-1",
name: "lighthouse-config-connection",
state: "PENDING",
insertedAt: "2026-06-24T10:01:00Z",
completedAt: null,
},
data: { ...configurations[0], connected: true },
});
updateConfigurationMock.mockResolvedValue({ data: configurations[0] });
});
@@ -310,26 +301,25 @@ describe("LighthouseV2ConfigPage", () => {
expect(updateConfigurationMock).not.toHaveBeenCalled();
});
it("starts a connection test and exposes a refresh status action", async () => {
it("tests the connection and reports the resulting status", async () => {
// Given
const user = userEvent.setup();
testConnectionMock.mockResolvedValue({
data: { ...configurations[0], connected: false },
});
renderPage();
// When
await user.click(screen.getByRole("button", { name: /Test connection/i }));
// Then
// Then: the action is polled to completion and the resulting status shown
await waitFor(() =>
expect(testConnectionMock).toHaveBeenCalledWith("config-openai"),
);
expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
expect(
await screen.findByText("Connection check started."),
).toBeInTheDocument();
await user.click(
screen.getAllByRole("button", { name: /Refresh status/i })[0],
);
expect(refreshMock).toHaveBeenCalledTimes(1);
screen.queryByRole("button", { name: /Refresh status/i }),
).not.toBeInTheDocument();
});
it("confirms before deleting an existing configuration", async () => {
@@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
@@ -36,7 +35,6 @@ export function LighthouseV2ConfigPage({
modelsByProvider,
error,
}: LighthouseV2ConfigPageProps) {
const router = useRouter();
const [localConfigurations, setLocalConfigurations] =
useState(configurations);
const [selectedProvider, setSelectedProvider] =
@@ -63,13 +61,17 @@ export function LighthouseV2ConfigPage({
? modelsByProvider[selectedProviderDefinition.id]
: [];
const handleConfigurationSaved = (
configuration: LighthouseV2Configuration,
) => {
const upsertConfiguration = (configuration: LighthouseV2Configuration) => {
setLocalConfigurations((current) => [
...current.filter((config) => config.id !== configuration.id),
configuration,
]);
};
const handleConfigurationSaved = (
configuration: LighthouseV2Configuration,
) => {
upsertConfiguration(configuration);
setSelectedProvider(configuration.providerType);
setFeedback({
title: "Configuration saved.",
@@ -78,6 +80,26 @@ export function LighthouseV2ConfigPage({
});
};
const handleConfigurationTested = (
configuration: LighthouseV2Configuration,
) => {
upsertConfiguration(configuration);
setFeedback(
configuration.connected
? {
title: "Connection successful.",
description: "Lighthouse can send messages with this provider.",
variant: FEEDBACK_VARIANT.SUCCESS,
}
: {
title: "Connection failed.",
description:
"Review the credentials and test the connection again.",
variant: FEEDBACK_VARIANT.ERROR,
},
);
};
const handleConfigurationDeleted = (configurationId: string) => {
setLocalConfigurations((current) =>
current.filter((config) => config.id !== configurationId),
@@ -105,7 +127,6 @@ export function LighthouseV2ConfigPage({
<div className="border-border-neutral-secondary border-b px-4 py-4 md:px-5">
<ConfigFeedbackAlert
feedback={feedback}
onRefreshStatus={() => router.refresh()}
onClose={() => setFeedback(null)}
/>
</div>
@@ -138,6 +159,7 @@ export function LighthouseV2ConfigPage({
provider={selectedProviderDefinition}
onConfigurationSaved={handleConfigurationSaved}
onConfigurationDeleted={handleConfigurationDeleted}
onConfigurationTested={handleConfigurationTested}
onFeedback={setFeedback}
/>
</div>
@@ -1,51 +0,0 @@
import { CheckCircle2, CircleDashed } from "lucide-react";
import { formatTokenLimit } from "@/app/(prowler)/lighthouse/_lib/format";
import { type LighthouseV2SupportedModel } from "@/app/(prowler)/lighthouse/_types";
export function ModelDetails({
model,
}: {
model?: LighthouseV2SupportedModel;
}) {
if (!model) {
return (
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary mt-3 rounded-[10px] border px-3 py-3">
<p className="text-text-neutral-secondary text-sm">
Select a model to see capabilities.
</p>
</div>
);
}
return (
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary mt-3 grid gap-3 rounded-[10px] border px-3 py-3 sm:grid-cols-3">
<CapabilityItem label="Tools" enabled={model.supportsFunctionCalling} />
<CapabilityItem label="Vision" enabled={model.supportsVision} />
<CapabilityItem label="Reasoning" enabled={model.supportsReasoning} />
<div className="text-text-neutral-secondary text-xs sm:col-span-3">
Input tokens: {formatTokenLimit(model.maxInputTokens)} · Output tokens:{" "}
{formatTokenLimit(model.maxOutputTokens)}
</div>
</div>
);
}
function CapabilityItem({
enabled,
label,
}: {
enabled: boolean | null;
label: string;
}) {
return (
<div className="flex items-center gap-2 text-sm">
{enabled ? (
<CheckCircle2 className="text-text-success-primary size-4" />
) : (
<CircleDashed className="text-text-neutral-tertiary size-4" />
)}
<span className="text-text-neutral-primary">{label}</span>
</div>
);
}
@@ -31,7 +31,6 @@ export interface FeedbackState {
title: string;
description?: string;
variant: FeedbackVariant;
showRefreshStatus?: boolean;
}
const lighthouseV2ConfigFormSchemaBase = z.object({
@@ -174,15 +173,3 @@ export function getConnectionStatus(
if (configuration?.connected === false) return CONNECTION_STATUS.FAILED;
return CONNECTION_STATUS.NOT_TESTED;
}
export function getConnectionStatusLabel(status: ConnectionStatus): string {
if (status === CONNECTION_STATUS.CONNECTED) return "Connected";
if (status === CONNECTION_STATUS.FAILED) return "Failed";
return "Not tested";
}
export function getAlertVariant(status: ConnectionStatus) {
if (status === CONNECTION_STATUS.CONNECTED) return "success";
if (status === CONNECTION_STATUS.FAILED) return "error";
return "info";
}
@@ -28,7 +28,3 @@ export function formatSessionAge(dateString: string): string {
if (ageInDays === 0) return "Today";
return ageInDays === 1 ? "1 day" : `${ageInDays} days`;
}
export function formatTokenLimit(value: number | null): string {
return value === null ? "Unknown" : value.toLocaleString();
}