mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 12:24:45 +00:00
274 lines
9.0 KiB
TypeScript
274 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { ExternalLink, Info } from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
|
import { usePanelSkillLaunch } from "@/components/lighthouse/use-panel-skill-launch";
|
|
import { Button } from "@/components/shadcn/button/button";
|
|
import {
|
|
LighthouseCallout,
|
|
LighthouseCalloutAction,
|
|
LighthouseCalloutActions,
|
|
LighthouseCalloutBody,
|
|
LighthouseCalloutDescription,
|
|
LighthouseCalloutIcon,
|
|
LighthouseCalloutTitle,
|
|
} from "@/components/shadcn/lighthouse-callout";
|
|
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
|
import { getVisibleSidePanelTabs } from "@/components/side-panel/side-panel-tabs";
|
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
import { useMountEffect } from "@/hooks/use-mount-effect";
|
|
import { buildProviderWizardContext } from "@/lib/lighthouse/context/contributions";
|
|
import { getSkillById } from "@/lib/lighthouse/skills/registry";
|
|
import {
|
|
dispatchProviderFunnel,
|
|
PROVIDER_FUNNEL_STEP,
|
|
} from "@/lib/provider-funnel/provider-funnel-events";
|
|
import { SIDE_PANEL_PUSH_MEDIA_QUERY } from "@/lib/ui-layout";
|
|
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
|
|
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
|
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
|
import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills";
|
|
import type { ProviderWizardMode } from "@/types/provider-wizard";
|
|
import { getProviderDisplayName } from "@/types/providers";
|
|
|
|
import {
|
|
buildProviderWizardHelpContext,
|
|
resolveConnectionMethod,
|
|
} from "./lighthouse-connect-help.utils";
|
|
|
|
const CONNECTION_GUIDE_SKILL = getSkillById(
|
|
LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE,
|
|
);
|
|
|
|
const HELP_STATE = {
|
|
DOCS: "docs",
|
|
CHECKING: "checking",
|
|
LIGHTHOUSE: "lighthouse",
|
|
} as const;
|
|
type HelpState = (typeof HELP_STATE)[keyof typeof HELP_STATE];
|
|
|
|
interface LighthouseConnectHelpProps {
|
|
docsLink: string;
|
|
docsDestination: string;
|
|
providerType: string | null;
|
|
mode: ProviderWizardMode;
|
|
// Stable step id for Lighthouse and analytics, and the label the stepper shows.
|
|
stepName: string;
|
|
stepLabel: string;
|
|
isOrganizationsFlow: boolean;
|
|
}
|
|
|
|
// Wizard help: Lighthouse AI when it can sit beside the wizard, else the docs.
|
|
export function LighthouseConnectHelp({
|
|
docsLink,
|
|
docsDestination,
|
|
providerType,
|
|
mode,
|
|
stepName,
|
|
stepLabel,
|
|
isOrganizationsFlow,
|
|
}: LighthouseConnectHelpProps) {
|
|
const helpState = useHelpState();
|
|
const isGuiding = useSidePanelStore(
|
|
(state) => state.isOpen && state.selectedTab === SIDE_PANEL_TAB.AI_CHAT,
|
|
);
|
|
const launchPanelSkill = usePanelSkillLaunch();
|
|
const providerUid = useProviderWizardStore((state) => state.providerUid);
|
|
const via = useProviderWizardStore((state) => state.via);
|
|
// Only the method leaves the AWS draft: its values may hold access keys.
|
|
const awsDraftMethod = useProviderWizardStore(
|
|
(state) => state.awsConnectDraft?.method,
|
|
);
|
|
|
|
if (helpState === HELP_STATE.DOCS || !CONNECTION_GUIDE_SKILL) {
|
|
return (
|
|
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
|
|
<Info className="size-4 shrink-0" />
|
|
<span>For assistance connecting a Provider visit</span>
|
|
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
|
|
<a href={docsLink} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="size-3.5 shrink-0" />
|
|
<span>{`${docsDestination} Documentation`}</span>
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const wizardContext = {
|
|
providerType,
|
|
// The store keeps the last provider's account across opens; an organization has none.
|
|
providerUid: isOrganizationsFlow ? null : providerUid,
|
|
mode,
|
|
stepName,
|
|
stepLabel,
|
|
connectionMethod: resolveConnectionMethod({
|
|
isOrganizationsFlow,
|
|
providerType,
|
|
via,
|
|
awsDraftMethod,
|
|
}),
|
|
docsUrl: docsLink,
|
|
};
|
|
|
|
const askLighthouse = () => {
|
|
dispatchProviderFunnel({
|
|
step: PROVIDER_FUNNEL_STEP.LIGHTHOUSE_HELP_OPENED,
|
|
providerType,
|
|
wizardStep: stepName,
|
|
});
|
|
launchPanelSkill(
|
|
CONNECTION_GUIDE_SKILL,
|
|
buildProviderWizardHelpContext(wizardContext),
|
|
);
|
|
};
|
|
|
|
const providerName = providerType
|
|
? getProviderDisplayName(providerType)
|
|
: null;
|
|
const copy = getHelpCopy({ isGuiding, providerName });
|
|
const calloutContent = (
|
|
<>
|
|
<LighthouseCalloutIcon />
|
|
<LighthouseCalloutBody>
|
|
<LighthouseCalloutTitle>{copy.title}</LighthouseCalloutTitle>
|
|
{/* A stacked wizard keeps the list in view: title and actions only. */}
|
|
<LighthouseCalloutDescription className="hidden lg:block">
|
|
{copy.description}
|
|
</LighthouseCalloutDescription>
|
|
</LighthouseCalloutBody>
|
|
<LighthouseCalloutActions>
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<a href={docsLink} target="_blank" rel="noopener noreferrer">
|
|
{`${docsDestination} docs`}
|
|
<ExternalLink aria-hidden className="size-3.5" />
|
|
</a>
|
|
</Button>
|
|
<LighthouseCalloutAction onClick={askLighthouse}>
|
|
{copy.action}
|
|
</LighthouseCalloutAction>
|
|
</LighthouseCalloutActions>
|
|
</>
|
|
);
|
|
|
|
if (helpState === HELP_STATE.CHECKING) {
|
|
return (
|
|
<div
|
|
role="status"
|
|
aria-label="Loading Lighthouse AI help"
|
|
className="relative mt-2"
|
|
>
|
|
{/* Invisible copy sizes the placeholder exactly like the callout. */}
|
|
<LighthouseCallout aria-hidden inert className="invisible">
|
|
{calloutContent}
|
|
</LighthouseCallout>
|
|
<Skeleton className="absolute inset-0 rounded-2xl" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<LighthouseCallout
|
|
aria-label="Help from Lighthouse AI"
|
|
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
|
|
>
|
|
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
|
|
{calloutContent}
|
|
{/* Follow-ups typed in the panel carry the wizard's current state. */}
|
|
<LighthouseContextContributor
|
|
contributorId="provider-wizard"
|
|
item={buildProviderWizardContext(wizardContext)}
|
|
/>
|
|
</LighthouseCallout>
|
|
);
|
|
}
|
|
|
|
interface HelpShownSignalProps {
|
|
providerType: string | null;
|
|
wizardStep: string;
|
|
}
|
|
|
|
// Mounted only while the callout renders: one exposure per wizard opening.
|
|
function HelpShownSignal({ providerType, wizardStep }: HelpShownSignalProps) {
|
|
useMountEffect(() => {
|
|
dispatchProviderFunnel({
|
|
step: PROVIDER_FUNNEL_STEP.LIGHTHOUSE_HELP_SHOWN,
|
|
providerType,
|
|
wizardStep,
|
|
});
|
|
});
|
|
return null;
|
|
}
|
|
|
|
interface HelpCopyInput {
|
|
isGuiding: boolean;
|
|
providerName: string | null;
|
|
}
|
|
|
|
function getHelpCopy({ isGuiding, providerName }: HelpCopyInput) {
|
|
if (isGuiding) {
|
|
return {
|
|
title: "Lighthouse AI is guiding you",
|
|
description: "Keep going here; its answers show up in the side panel.",
|
|
action: "Ask about this step",
|
|
};
|
|
}
|
|
return {
|
|
title: providerName
|
|
? `Need a hand connecting ${providerName}?`
|
|
: "Not sure how to connect your cloud?",
|
|
description:
|
|
"Lighthouse AI walks you through it with Prowler's docs, right beside this form.",
|
|
action: "Ask Lighthouse AI",
|
|
};
|
|
}
|
|
|
|
// Lighthouse AI can only help beside the wizard when the panel hosts the chat
|
|
// (cloud), the panel pushes instead of covering it, and an LLM is connected.
|
|
function useHelpState(): HelpState {
|
|
const canHostChat = getVisibleSidePanelTabs().some(
|
|
(tab) => tab.id === SIDE_PANEL_TAB.AI_CHAT,
|
|
);
|
|
const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY);
|
|
// Kept across openings; every opening still refreshes it.
|
|
const hasConnectedLlm = useLighthouseLlmStore(
|
|
(state) => state.hasConnectedLlm,
|
|
);
|
|
const startCheck = useLighthouseLlmStore((state) => state.startCheck);
|
|
const settleCheck = useLighthouseLlmStore((state) => state.settleCheck);
|
|
// Local state needed: with nothing remembered, a failed check shows the docs.
|
|
const [checkFailed, setCheckFailed] = useState(false);
|
|
|
|
useMountEffect(() => {
|
|
if (!canHostChat) return;
|
|
let isMounted = true;
|
|
const check = startCheck();
|
|
// Lazy: the Lighthouse actions only load where the chat can exist (cloud).
|
|
import("@/app/(prowler)/lighthouse/_actions")
|
|
.then(({ getLighthouseV2Configurations }) =>
|
|
getLighthouseV2Configurations(),
|
|
)
|
|
.then((result) =>
|
|
"error" in result
|
|
? null
|
|
: result.data.some((configuration) => configuration.connected),
|
|
)
|
|
// Optional help: a failed check keeps the last answer, else the docs.
|
|
.catch(() => null)
|
|
.then((connected) => {
|
|
if (connected !== null) settleCheck(check, connected);
|
|
else if (isMounted) setCheckFailed(true);
|
|
});
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
});
|
|
|
|
if (!canHostChat || !isPushViewport) return HELP_STATE.DOCS;
|
|
if (hasConnectedLlm === null)
|
|
return checkFailed ? HELP_STATE.DOCS : HELP_STATE.CHECKING;
|
|
return hasConnectedLlm ? HELP_STATE.LIGHTHOUSE : HELP_STATE.DOCS;
|
|
}
|