diff --git a/backend/app/api/skills_marketplace.py b/backend/app/api/skills_marketplace.py index 7cd22c67..27e7b399 100644 --- a/backend/app/api/skills_marketplace.py +++ b/backend/app/api/skills_marketplace.py @@ -14,6 +14,7 @@ from urllib.parse import unquote, urlparse from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, or_ from sqlmodel import col from app.api.deps import require_org_admin @@ -940,13 +941,69 @@ def _apply_pack_candidate_updates( @router.get("/marketplace", response_model=list[MarketplaceSkillCardRead]) async def list_marketplace_skills( gateway_id: UUID = GATEWAY_ID_QUERY, + search: str | None = Query(default=None), + category: str | None = Query(default=None), + risk: str | None = Query(default=None), + pack_id: UUID | None = Query(default=None, alias="pack_id"), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> list[MarketplaceSkillCardRead]: """List marketplace cards for an org and annotate install state for a gateway.""" gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) + skills_query = MarketplaceSkill.objects.filter_by(organization_id=ctx.organization.id) + + normalized_category = (category or "").strip().lower() + if normalized_category: + if normalized_category == "uncategorized": + skills_query = skills_query.filter( + or_( + col(MarketplaceSkill.category).is_(None), + func.trim(col(MarketplaceSkill.category)) == "", + ), + ) + else: + skills_query = skills_query.filter( + func.lower(func.trim(col(MarketplaceSkill.category))) + == normalized_category, + ) + + normalized_risk = (risk or "").strip().lower() + if normalized_risk: + if normalized_risk == "uncategorized": + skills_query = skills_query.filter( + or_( + col(MarketplaceSkill.risk).is_(None), + func.trim(col(MarketplaceSkill.risk)) == "", + ), + ) + else: + skills_query = skills_query.filter( + func.lower(func.trim(func.coalesce(col(MarketplaceSkill.risk), ""))) + == normalized_risk, + ) + + if pack_id is not None: + pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx) + normalized_pack_source = _normalize_pack_source_url(pack.source_url) + skills_query = skills_query.filter( + col(MarketplaceSkill.source_url).ilike(f"{normalized_pack_source}%"), + ) + + normalized_search = (search or "").strip() + if normalized_search: + search_like = f"%{normalized_search}%" + skills_query = skills_query.filter( + or_( + col(MarketplaceSkill.name).ilike(search_like), + col(MarketplaceSkill.description).ilike(search_like), + col(MarketplaceSkill.category).ilike(search_like), + col(MarketplaceSkill.risk).ilike(search_like), + col(MarketplaceSkill.source).ilike(search_like), + ), + ) + skills = ( - await MarketplaceSkill.objects.filter_by(organization_id=ctx.organization.id) + await skills_query .order_by(col(MarketplaceSkill.created_at).desc()) .all(session) ) diff --git a/frontend/src/app/skills/marketplace/page.tsx b/frontend/src/app/skills/marketplace/page.tsx index 83ade27b..770fe28f 100644 --- a/frontend/src/app/skills/marketplace/page.tsx +++ b/frontend/src/app/skills/marketplace/page.tsx @@ -31,11 +31,15 @@ import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTab 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"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [ "name", @@ -45,6 +49,75 @@ const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [ "updated_at", ]; +type MarketplaceSkillListParams = + listMarketplaceSkillsApiV1SkillsMarketplaceGetParams & { + search?: string; + category?: string; + risk?: string; + pack_id?: string; + }; + +const RISK_SORT_ORDER: Record = { + safe: 10, + low: 20, + minimal: 30, + medium: 40, + moderate: 50, + elevated: 60, + high: 70, + critical: 80, + none: 90, + unknown: 100, +}; + +function formatRiskLabel(risk: string) { + const normalized = risk.trim().toLowerCase(); + if (!normalized) { + return "Unknown"; + } + + switch (normalized) { + case "safe": + return "Safe"; + case "low": + return "Low"; + case "minimal": + return "Minimal"; + case "medium": + return "Medium"; + case "moderate": + return "Moderate"; + case "elevated": + return "Elevated"; + case "high": + return "High"; + case "critical": + return "Critical"; + case "none": + return "None"; + case "unknown": + return "Unknown"; + default: + return normalized + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + } +} + +function formatCategoryLabel(category: string) { + const normalized = category.trim(); + if (!normalized) { + return "Uncategorized"; + } + return normalized + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + export default function SkillsMarketplacePage() { const queryClient = useQueryClient(); const searchParams = useSearchParams(); @@ -64,6 +137,9 @@ export default function SkillsMarketplacePage() { const [installingGatewayId, setInstallingGatewayId] = useState( null, ); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [selectedRisk, setSelectedRisk] = useState("safe"); const { sorting, onSortingChange } = useUrlSorting({ allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS, @@ -91,12 +167,52 @@ export default function SkillsMarketplacePage() { ); const resolvedGatewayId = gateways[0]?.id ?? ""; + const normalizedCategory = useMemo(() => { + const value = selectedCategory.trim().toLowerCase(); + return value.length > 0 ? value : "all"; + }, [selectedCategory]); + const normalizedRisk = useMemo(() => { + const value = selectedRisk.trim().toLowerCase(); + return value.length > 0 ? value : "safe"; + }, [selectedRisk]); + const normalizedSearch = useMemo(() => searchTerm.trim(), [searchTerm]); + const selectedPackId = searchParams.get("packId"); + const skillsParams = useMemo(() => { + const params: MarketplaceSkillListParams = { + gateway_id: resolvedGatewayId, + }; + if (normalizedSearch) { + params.search = normalizedSearch; + } + if (normalizedCategory !== "all") { + params.category = normalizedCategory; + } + if (normalizedRisk && normalizedRisk !== "all") { + params.risk = normalizedRisk; + } + if (selectedPackId) { + params.pack_id = selectedPackId; + } + return params; + }, [normalizedCategory, normalizedRisk, normalizedSearch, resolvedGatewayId, selectedPackId]); + const filterOptionsParams = useMemo(() => { + const params: MarketplaceSkillListParams = { + gateway_id: resolvedGatewayId, + }; + if (normalizedSearch) { + params.search = normalizedSearch; + } + if (selectedPackId) { + params.pack_id = selectedPackId; + } + return params; + }, [normalizedSearch, resolvedGatewayId, selectedPackId]); const skillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet< listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, ApiError >( - { gateway_id: resolvedGatewayId }, + skillsParams, { query: { enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId), @@ -110,6 +226,26 @@ export default function SkillsMarketplacePage() { () => (skillsQuery.data?.status === 200 ? skillsQuery.data.data : []), [skillsQuery.data], ); + const filterOptionSkillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet< + listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, + ApiError + >( + filterOptionsParams, + { + query: { + enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId), + refetchOnMount: "always", + refetchInterval: 15_000, + }, + }, + ); + const filterOptionSkills = useMemo( + () => + filterOptionSkillsQuery.data?.status === 200 + ? filterOptionSkillsQuery.data.data + : [], + [filterOptionSkillsQuery.data], + ); const packsQuery = useListSkillPacksApiV1SkillsPacksGet< listSkillPacksApiV1SkillsPacksGetResponse, @@ -125,21 +261,74 @@ export default function SkillsMarketplacePage() { () => (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; + const filteredSkills = useMemo(() => skills, [skills]); + + const categoryFilterOptions = useMemo(() => { + const byValue = new Map(); + for (const skill of filterOptionSkills) { + const raw = (skill.category || "Uncategorized").trim(); + const label = raw.length > 0 ? raw : "Uncategorized"; + const value = label.trim().toLowerCase(); + if (!value || value === "all" || byValue.has(value)) { + continue; + } + byValue.set(value, label); + } + if (normalizedCategory !== "all" && !byValue.has(normalizedCategory)) { + byValue.set(normalizedCategory, formatCategoryLabel(normalizedCategory)); + } + return Array.from(byValue.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [filterOptionSkills, normalizedCategory]); + + const riskFilterOptions = useMemo(() => { + const set = new Set(); + for (const skill of filterOptionSkills) { + const risk = (skill.risk || "unknown").trim().toLowerCase(); + const normalized = risk.length > 0 ? risk : "unknown"; + if (normalized !== "all") { + set.add(normalized); + } + } + if (normalizedRisk !== "all") { + set.add(normalizedRisk); + } + const risks = Array.from(set); + return risks.sort((a, b) => { + const rankA = RISK_SORT_ORDER[a] ?? 1000; + const rankB = RISK_SORT_ORDER[b] ?? 1000; + if (rankA !== rankB) { + return rankA - rankB; + } + return a.localeCompare(b); }); - }, [selectedPack, skills]); + }, [filterOptionSkills, normalizedRisk]); + + useEffect(() => { + if ( + selectedCategory !== "all" && + !categoryFilterOptions.some( + (category) => category.value === selectedCategory.trim().toLowerCase(), + ) + ) { + setSelectedCategory("all"); + } + }, [categoryFilterOptions, selectedCategory]); + + useEffect(() => { + if ( + selectedRisk !== "all" && + !riskFilterOptions.includes(selectedRisk.trim().toLowerCase()) + ) { + setSelectedRisk("safe"); + } + }, [riskFilterOptions, selectedRisk]); const loadSkillsByGateway = useCallback(async () => { // NOTE: This is technically N+1 (one request per gateway). We intentionally @@ -411,11 +600,11 @@ export default function SkillsMarketplacePage() { title="Skills Marketplace" description={ selectedPack - ? `${visibleSkills.length} skill${ - visibleSkills.length === 1 ? "" : "s" + ? `${filteredSkills.length} skill${ + filteredSkills.length === 1 ? "" : "s" } for ${selectedPack.name}.` - : `${visibleSkills.length} skill${ - visibleSkills.length === 1 ? "" : "s" + : `${filteredSkills.length} skill${ + filteredSkills.length === 1 ? "" : "s" } synced from packs.` } isAdmin={isAdmin} @@ -440,9 +629,79 @@ export default function SkillsMarketplacePage() { ) : ( <> +
+
+
+ + setSearchTerm(event.target.value)} + placeholder="Search by name, description, category, pack, source..." + type="search" + /> +
+
+ + +
+
+ + +
+
+
( {riskBadgeLabel(row.original.risk)}