"use client"; export const dynamic = "force-dynamic"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useAuth } from "@/auth/clerk"; import { useQueryClient } from "@tanstack/react-query"; import { ApiError } from "@/api/mutator"; import { type listGatewaysApiV1GatewaysGetResponse, useListGatewaysApiV1GatewaysGet, } from "@/api/generated/gateways/gateways"; import type { MarketplaceSkillCardRead } from "@/api/generated/model"; import { listMarketplaceSkillsApiV1SkillsMarketplaceGet, type listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost, useListMarketplaceSkillsApiV1SkillsMarketplaceGet, useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost, } from "@/api/generated/skills-marketplace/skills-marketplace"; import { type listSkillPacksApiV1SkillsPacksGetResponse, useListSkillPacksApiV1SkillsPacksGet, } from "@/api/generated/skills/skills"; import { SkillInstallDialog } from "@/components/skills/SkillInstallDialog"; import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTable"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { buttonVariants } from "@/components/ui/button"; import { useOrganizationMembership } from "@/lib/use-organization-membership"; import { normalizeRepoSourceUrl, repoBaseFromSkillSourceUrl, } from "@/lib/skills-source"; import { useUrlSorting } from "@/lib/use-url-sorting"; const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [ "name", "category", "risk", "source", "updated_at", ]; export default function SkillsMarketplacePage() { const queryClient = useQueryClient(); const searchParams = useSearchParams(); const { isSignedIn } = useAuth(); const { isAdmin } = useOrganizationMembership(isSignedIn); const [selectedSkill, setSelectedSkill] = useState(null); const [gatewayInstalledById, setGatewayInstalledById] = useState< Record >({}); const [installedGatewayNamesBySkillId, setInstalledGatewayNamesBySkillId] = useState< Record >({}); const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false); const [gatewayStatusError, setGatewayStatusError] = useState(null); const [installingGatewayId, setInstallingGatewayId] = useState(null); const { sorting, onSortingChange } = useUrlSorting({ allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS, defaultSorting: [{ id: "name", desc: false }], paramPrefix: "skills_marketplace", }); const gatewaysQuery = useListGatewaysApiV1GatewaysGet< listGatewaysApiV1GatewaysGetResponse, ApiError >(undefined, { query: { enabled: Boolean(isSignedIn && isAdmin), refetchOnMount: "always", refetchInterval: 30_000, }, }); const gateways = useMemo( () => gatewaysQuery.data?.status === 200 ? (gatewaysQuery.data.data.items ?? []) : [], [gatewaysQuery.data], ); const resolvedGatewayId = gateways[0]?.id ?? ""; const skillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet< listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, ApiError >( { gateway_id: resolvedGatewayId }, { query: { enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId), refetchOnMount: "always", refetchInterval: 15_000, }, }, ); const skills = useMemo( () => (skillsQuery.data?.status === 200 ? skillsQuery.data.data : []), [skillsQuery.data], ); const packsQuery = useListSkillPacksApiV1SkillsPacksGet< listSkillPacksApiV1SkillsPacksGetResponse, ApiError >({ query: { enabled: Boolean(isSignedIn && isAdmin), refetchOnMount: "always", }, }); const packs = useMemo( () => (packsQuery.data?.status === 200 ? packsQuery.data.data : []), [packsQuery.data], ); const selectedPackId = searchParams.get("packId"); const selectedPack = useMemo( () => packs.find((pack) => pack.id === selectedPackId) ?? null, [packs, selectedPackId], ); const visibleSkills = useMemo(() => { if (!selectedPack) return skills; const selectedRepo = normalizeRepoSourceUrl(selectedPack.source_url); return skills.filter((skill) => { const skillRepo = repoBaseFromSkillSourceUrl(skill.source_url); return skillRepo === selectedRepo; }); }, [selectedPack, skills]); const loadSkillsByGateway = useCallback(async () => { // NOTE: This is technically N+1 (one request per gateway). We intentionally // parallelize requests to keep the UI responsive and avoid slow sequential // fetches. If this becomes a bottleneck for large gateway counts, add a // backend batch endpoint to return installation state across all gateways. const gatewaySkills = await Promise.all( gateways.map(async (gateway) => { const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({ gateway_id: gateway.id, }); return { gatewayId: gateway.id, gatewayName: gateway.name, skills: response.status === 200 ? response.data : [], }; }), ); return gatewaySkills; }, [gateways]); const updateInstalledGatewayNames = useCallback( ({ skillId, gatewayName, installed, }: { skillId: string; gatewayName: string; installed: boolean; }) => { setInstalledGatewayNamesBySkillId((previous) => { const installedOn = previous[skillId] ?? []; if (installed) { if (installedOn.includes(gatewayName)) return previous; return { ...previous, [skillId]: [...installedOn, gatewayName], }; } return { ...previous, [skillId]: installedOn.filter((name) => name !== gatewayName), }; }); }, [], ); useEffect(() => { let cancelled = false; const loadInstalledGatewaysBySkill = async () => { if (!isSignedIn || !isAdmin || gateways.length === 0 || skills.length === 0) { setInstalledGatewayNamesBySkillId({}); return; } try { const gatewaySkills = await Promise.all( gateways.map(async (gateway) => { const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({ gateway_id: gateway.id, }); return { gatewayId: gateway.id, gatewayName: gateway.name, skills: response.status === 200 ? response.data : [], }; }), ); if (cancelled) return; const nextInstalledGatewayNamesBySkillId: Record = {}; for (const skill of skills) { nextInstalledGatewayNamesBySkillId[skill.id] = []; } for (const { gatewayName, skills: gatewaySkillRows } of gatewaySkills) { for (const skill of gatewaySkillRows) { if (!skill.installed) continue; if (!nextInstalledGatewayNamesBySkillId[skill.id]) continue; nextInstalledGatewayNamesBySkillId[skill.id].push(gatewayName); } } setInstalledGatewayNamesBySkillId(nextInstalledGatewayNamesBySkillId); } catch { if (cancelled) return; setInstalledGatewayNamesBySkillId({}); } }; void loadInstalledGatewaysBySkill(); return () => { cancelled = true; }; }, [gateways, isAdmin, isSignedIn, skills]); const installMutation = useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost( { mutation: { onSuccess: async (_, variables) => { await queryClient.invalidateQueries({ queryKey: ["/api/v1/skills/marketplace"], }); setGatewayInstalledById((previous) => ({ ...previous, [variables.params.gateway_id]: true, })); const gatewayName = gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name; if (gatewayName) { updateInstalledGatewayNames({ skillId: variables.skillId, gatewayName, installed: true, }); } }, }, }, queryClient, ); const uninstallMutation = useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost( { mutation: { onSuccess: async (_, variables) => { await queryClient.invalidateQueries({ queryKey: ["/api/v1/skills/marketplace"], }); setGatewayInstalledById((previous) => ({ ...previous, [variables.params.gateway_id]: false, })); const gatewayName = gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name; if (gatewayName) { updateInstalledGatewayNames({ skillId: variables.skillId, gatewayName, installed: false, }); } }, }, }, queryClient, ); useEffect(() => { let cancelled = false; const loadGatewayStatus = async () => { if (!selectedSkill) { setGatewayInstalledById({}); setGatewayStatusError(null); setIsGatewayStatusLoading(false); return; } if (gateways.length === 0) { setGatewayInstalledById({}); setGatewayStatusError(null); setIsGatewayStatusLoading(false); return; } setIsGatewayStatusLoading(true); setGatewayStatusError(null); try { const gatewaySkills = await loadSkillsByGateway(); const entries = gatewaySkills.map(({ gatewayId, skills: gatewaySkillRows }) => { const row = gatewaySkillRows.find((skill) => skill.id === selectedSkill.id); return [gatewayId, Boolean(row?.installed)] as const; }); if (cancelled) return; setGatewayInstalledById(Object.fromEntries(entries)); } catch (error) { if (cancelled) return; setGatewayStatusError( error instanceof Error ? error.message : "Unable to load gateway status.", ); } finally { if (!cancelled) { setIsGatewayStatusLoading(false); } } }; void loadGatewayStatus(); return () => { cancelled = true; }; }, [gateways, loadSkillsByGateway, selectedSkill]); const mutationError = installMutation.error?.message ?? uninstallMutation.error?.message ?? null; const isMutating = installMutation.isPending || uninstallMutation.isPending; const handleGatewayInstallAction = async ( gatewayId: string, isInstalled: boolean, ) => { if (!selectedSkill) return; setInstallingGatewayId(gatewayId); try { if (isInstalled) { await uninstallMutation.mutateAsync({ skillId: selectedSkill.id, params: { gateway_id: gatewayId }, }); } else { await installMutation.mutateAsync({ skillId: selectedSkill.id, params: { gateway_id: gatewayId }, }); } } finally { setInstallingGatewayId(null); } }; return ( <>
{gateways.length === 0 ? (

No gateways available yet.

Create a gateway first, then return here to manage installs.

Create gateway
) : ( <>
)} {skillsQuery.error ? (

{skillsQuery.error.message}

) : null} {packsQuery.error ? (

{packsQuery.error.message}

) : null} {mutationError ?

{mutationError}

: null}
{ if (!open) { setSelectedSkill(null); } }} onToggleInstall={(gatewayId, isInstalled) => { void handleGatewayInstallAction(gatewayId, isInstalled); }} /> ); }