feat(skills): add filtering options for category and risk in marketplace skills

This commit is contained in:
Abhimanyu Saharan
2026-02-14 12:58:19 +05:30
parent a4410373cb
commit 755bbde4f5
3 changed files with 364 additions and 22 deletions

View File

@@ -14,6 +14,7 @@ from urllib.parse import unquote, urlparse
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_
from sqlmodel import col from sqlmodel import col
from app.api.deps import require_org_admin from app.api.deps import require_org_admin
@@ -940,13 +941,69 @@ def _apply_pack_candidate_updates(
@router.get("/marketplace", response_model=list[MarketplaceSkillCardRead]) @router.get("/marketplace", response_model=list[MarketplaceSkillCardRead])
async def list_marketplace_skills( async def list_marketplace_skills(
gateway_id: UUID = GATEWAY_ID_QUERY, 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, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> list[MarketplaceSkillCardRead]: ) -> list[MarketplaceSkillCardRead]:
"""List marketplace cards for an org and annotate install state for a gateway.""" """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) 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 = ( skills = (
await MarketplaceSkill.objects.filter_by(organization_id=ctx.organization.id) await skills_query
.order_by(col(MarketplaceSkill.created_at).desc()) .order_by(col(MarketplaceSkill.created_at).desc())
.all(session) .all(session)
) )

View File

@@ -31,11 +31,15 @@ import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTab
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { useOrganizationMembership } from "@/lib/use-organization-membership"; import { useOrganizationMembership } from "@/lib/use-organization-membership";
import {
normalizeRepoSourceUrl,
repoBaseFromSkillSourceUrl,
} from "@/lib/skills-source";
import { useUrlSorting } from "@/lib/use-url-sorting"; 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 = [ const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [
"name", "name",
@@ -45,6 +49,75 @@ const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [
"updated_at", "updated_at",
]; ];
type MarketplaceSkillListParams =
listMarketplaceSkillsApiV1SkillsMarketplaceGetParams & {
search?: string;
category?: string;
risk?: string;
pack_id?: string;
};
const RISK_SORT_ORDER: Record<string, number> = {
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() { export default function SkillsMarketplacePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -64,6 +137,9 @@ export default function SkillsMarketplacePage() {
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>( const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(
null, null,
); );
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [selectedRisk, setSelectedRisk] = useState<string>("safe");
const { sorting, onSortingChange } = useUrlSorting({ const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS, allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS,
@@ -91,12 +167,52 @@ export default function SkillsMarketplacePage() {
); );
const resolvedGatewayId = gateways[0]?.id ?? ""; 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<MarketplaceSkillListParams>(() => {
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<MarketplaceSkillListParams>(() => {
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< const skillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse, listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse,
ApiError ApiError
>( >(
{ gateway_id: resolvedGatewayId }, skillsParams,
{ {
query: { query: {
enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId), enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId),
@@ -110,6 +226,26 @@ export default function SkillsMarketplacePage() {
() => (skillsQuery.data?.status === 200 ? skillsQuery.data.data : []), () => (skillsQuery.data?.status === 200 ? skillsQuery.data.data : []),
[skillsQuery.data], [skillsQuery.data],
); );
const filterOptionSkillsQuery = useListMarketplaceSkillsApiV1SkillsMarketplaceGet<
listMarketplaceSkillsApiV1SkillsMarketplaceGetResponse,
ApiError
>(
filterOptionsParams,
{
query: {
enabled: Boolean(isSignedIn && isAdmin && resolvedGatewayId),
refetchOnMount: "always",
refetchInterval: 15_000,
},
},
);
const filterOptionSkills = useMemo<MarketplaceSkillCardRead[]>(
() =>
filterOptionSkillsQuery.data?.status === 200
? filterOptionSkillsQuery.data.data
: [],
[filterOptionSkillsQuery.data],
);
const packsQuery = useListSkillPacksApiV1SkillsPacksGet< const packsQuery = useListSkillPacksApiV1SkillsPacksGet<
listSkillPacksApiV1SkillsPacksGetResponse, listSkillPacksApiV1SkillsPacksGetResponse,
@@ -125,21 +261,74 @@ export default function SkillsMarketplacePage() {
() => (packsQuery.data?.status === 200 ? packsQuery.data.data : []), () => (packsQuery.data?.status === 200 ? packsQuery.data.data : []),
[packsQuery.data], [packsQuery.data],
); );
const selectedPackId = searchParams.get("packId");
const selectedPack = useMemo( const selectedPack = useMemo(
() => packs.find((pack) => pack.id === selectedPackId) ?? null, () => packs.find((pack) => pack.id === selectedPackId) ?? null,
[packs, selectedPackId], [packs, selectedPackId],
); );
const visibleSkills = useMemo(() => { const filteredSkills = useMemo(() => skills, [skills]);
if (!selectedPack) return skills;
const selectedRepo = normalizeRepoSourceUrl(selectedPack.source_url); const categoryFilterOptions = useMemo(() => {
return skills.filter((skill) => { const byValue = new Map<string, string>();
const skillRepo = repoBaseFromSkillSourceUrl(skill.source_url); for (const skill of filterOptionSkills) {
return skillRepo === selectedRepo; 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<string>();
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 () => { const loadSkillsByGateway = useCallback(async () => {
// NOTE: This is technically N+1 (one request per gateway). We intentionally // NOTE: This is technically N+1 (one request per gateway). We intentionally
@@ -411,11 +600,11 @@ export default function SkillsMarketplacePage() {
title="Skills Marketplace" title="Skills Marketplace"
description={ description={
selectedPack selectedPack
? `${visibleSkills.length} skill${ ? `${filteredSkills.length} skill${
visibleSkills.length === 1 ? "" : "s" filteredSkills.length === 1 ? "" : "s"
} for ${selectedPack.name}.` } for ${selectedPack.name}.`
: `${visibleSkills.length} skill${ : `${filteredSkills.length} skill${
visibleSkills.length === 1 ? "" : "s" filteredSkills.length === 1 ? "" : "s"
} synced from packs.` } synced from packs.`
} }
isAdmin={isAdmin} isAdmin={isAdmin}
@@ -440,9 +629,79 @@ export default function SkillsMarketplacePage() {
</div> </div>
) : ( ) : (
<> <>
<div className="mb-5 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-4 md:grid-cols-[1fr_240px_240px]">
<div>
<label
htmlFor="marketplace-search"
className="mb-1 block text-sm font-medium text-slate-700"
>
Search
</label>
<Input
id="marketplace-search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search by name, description, category, pack, source..."
type="search"
/>
</div>
<div>
<label
htmlFor="marketplace-category-filter"
className="mb-1 block text-sm font-medium text-slate-700"
>
Category
</label>
<Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger
id="marketplace-category-filter"
className="h-11"
>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{categoryFilterOptions.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label
htmlFor="marketplace-risk-filter"
className="mb-1 block text-sm font-medium text-slate-700"
>
Risk
</label>
<Select value={selectedRisk} onValueChange={setSelectedRisk}>
<SelectTrigger
id="marketplace-risk-filter"
className="h-11"
>
<SelectValue placeholder="Safe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All risks</SelectItem>
{riskFilterOptions.map((risk) => (
<SelectItem key={risk} value={risk}>
{formatRiskLabel(risk)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<MarketplaceSkillsTable <MarketplaceSkillsTable
skills={visibleSkills} skills={filteredSkills}
installedGatewayNamesBySkillId={ installedGatewayNamesBySkillId={
installedGatewayNamesBySkillId installedGatewayNamesBySkillId
} }

View File

@@ -33,14 +33,18 @@ function riskBadgeVariant(risk: string | null | undefined) {
const normalizedRisk = (risk || "unknown").trim().toLowerCase(); const normalizedRisk = (risk || "unknown").trim().toLowerCase();
switch (normalizedRisk) { switch (normalizedRisk) {
case "safe":
case "low": case "low":
return "success"; return "success";
case "minimal":
case "medium": case "medium":
case "moderate": case "moderate":
return "warning"; return "outline";
case "high": case "high":
case "critical": case "critical":
return "danger"; return "danger";
case "elevated":
return "warning";
case "unknown": case "unknown":
return "outline"; return "outline";
default: default:
@@ -48,6 +52,28 @@ function riskBadgeVariant(risk: string | null | undefined) {
} }
} }
function riskPillClassName(risk: string | null | undefined) {
const normalizedRisk = (risk || "unknown").trim().toLowerCase();
switch (normalizedRisk) {
case "safe":
case "low":
return "bg-[color:rgba(16,185,129,0.16)] text-emerald-800 border border-emerald-200/70";
case "medium":
case "moderate":
return "bg-[color:rgba(245,158,11,0.16)] text-amber-800 border border-amber-200/70";
case "elevated":
return "bg-[color:rgba(245,158,11,0.16)] text-amber-800 border border-amber-200/70";
case "high":
case "critical":
return "bg-[color:rgba(244,63,94,0.16)] text-rose-800 border border-rose-200/70";
case "unknown":
return "bg-[color:rgba(148,163,184,0.16)] text-slate-700 border border-slate-200/80";
default:
return "bg-[color:rgba(99,102,241,0.16)] text-indigo-800 border border-indigo-200/70";
}
}
function riskBadgeLabel(risk: string | null | undefined) { function riskBadgeLabel(risk: string | null | undefined) {
return (risk || "unknown").trim() || "unknown"; return (risk || "unknown").trim() || "unknown";
} }
@@ -148,7 +174,7 @@ export function MarketplaceSkillsTable({
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge
variant={riskBadgeVariant(row.original.risk)} variant={riskBadgeVariant(row.original.risk)}
className="px-2 py-0.5" className={`px-2 py-0.5 ${riskPillClassName(row.original.risk)} font-semibold`}
> >
{riskBadgeLabel(row.original.risk)} {riskBadgeLabel(row.original.risk)}
</Badge> </Badge>