459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
"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<MarketplaceSkillCardRead | null>(null);
|
|
const [gatewayInstalledById, setGatewayInstalledById] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [installedGatewayNamesBySkillId, setInstalledGatewayNamesBySkillId] = useState<
|
|
Record<string, string[]>
|
|
>({});
|
|
const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false);
|
|
const [gatewayStatusError, setGatewayStatusError] = useState<string | null>(null);
|
|
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(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<MarketplaceSkillCardRead[]>(
|
|
() => (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<string, string[]> = {};
|
|
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<ApiError>(
|
|
{
|
|
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<ApiError>(
|
|
{
|
|
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 (
|
|
<>
|
|
<DashboardPageLayout
|
|
signedOut={{
|
|
message: "Sign in to manage marketplace skills.",
|
|
forceRedirectUrl: "/skills/marketplace",
|
|
}}
|
|
title="Skills Marketplace"
|
|
description={
|
|
selectedPack
|
|
? `${visibleSkills.length} skill${
|
|
visibleSkills.length === 1 ? "" : "s"
|
|
} for ${selectedPack.name}.`
|
|
: `${visibleSkills.length} skill${
|
|
visibleSkills.length === 1 ? "" : "s"
|
|
} synced from packs.`
|
|
}
|
|
isAdmin={isAdmin}
|
|
adminOnlyMessage="Only organization owners and admins can manage skills."
|
|
stickyHeader
|
|
>
|
|
<div className="space-y-6">
|
|
{gateways.length === 0 ? (
|
|
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 shadow-sm">
|
|
<p className="font-medium text-slate-900">No gateways available yet.</p>
|
|
<p className="mt-2">
|
|
Create a gateway first, then return here to manage installs.
|
|
</p>
|
|
<Link
|
|
href="/gateways/new"
|
|
className={`${buttonVariants({ variant: "primary", size: "md" })} mt-4`}
|
|
>
|
|
Create gateway
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
<MarketplaceSkillsTable
|
|
skills={visibleSkills}
|
|
installedGatewayNamesBySkillId={installedGatewayNamesBySkillId}
|
|
isLoading={skillsQuery.isLoading}
|
|
sorting={sorting}
|
|
onSortingChange={onSortingChange}
|
|
stickyHeader
|
|
isMutating={isMutating}
|
|
onSkillClick={setSelectedSkill}
|
|
emptyState={{
|
|
title: "No marketplace skills yet",
|
|
description: "Add packs first, then synced skills will appear here.",
|
|
actionHref: "/skills/packs/new",
|
|
actionLabel: "Add your first pack",
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{skillsQuery.error ? (
|
|
<p className="text-sm text-rose-600">{skillsQuery.error.message}</p>
|
|
) : null}
|
|
{packsQuery.error ? (
|
|
<p className="text-sm text-rose-600">{packsQuery.error.message}</p>
|
|
) : null}
|
|
{mutationError ? <p className="text-sm text-rose-600">{mutationError}</p> : null}
|
|
</div>
|
|
</DashboardPageLayout>
|
|
|
|
<SkillInstallDialog
|
|
selectedSkill={selectedSkill}
|
|
gateways={gateways}
|
|
gatewayInstalledById={gatewayInstalledById}
|
|
isGatewayStatusLoading={isGatewayStatusLoading}
|
|
installingGatewayId={installingGatewayId}
|
|
isMutating={isMutating}
|
|
gatewayStatusError={gatewayStatusError}
|
|
mutationError={mutationError}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setSelectedSkill(null);
|
|
}
|
|
}}
|
|
onToggleInstall={(gatewayId, isInstalled) => {
|
|
void handleGatewayInstallAction(gatewayId, isInstalled);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|