feat(skills): add filtering options for category and risk in marketplace skills
This commit is contained in:
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user