diff --git a/backend/app/api/skills_marketplace.py b/backend/app/api/skills_marketplace.py index 27e7b399..53df8462 100644 --- a/backend/app/api/skills_marketplace.py +++ b/backend/app/api/skills_marketplace.py @@ -13,9 +13,9 @@ from typing import TYPE_CHECKING, Iterator, TextIO from urllib.parse import unquote, urlparse from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import func, or_ -from sqlmodel import col +from sqlmodel import col, select from app.api.deps import require_org_admin from app.core.time import utcnow @@ -940,11 +940,14 @@ def _apply_pack_candidate_updates( @router.get("/marketplace", response_model=list[MarketplaceSkillCardRead]) async def list_marketplace_skills( + response: Response, 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"), + limit: int | None = Query(default=None, ge=1, le=200), + offset: int = Query(default=0, ge=0), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> list[MarketplaceSkillCardRead]: @@ -1002,11 +1005,19 @@ async def list_marketplace_skills( ), ) - skills = ( - await skills_query - .order_by(col(MarketplaceSkill.created_at).desc()) - .all(session) - ) + if limit is not None: + count_statement = select(func.count()).select_from( + skills_query.statement.order_by(None).subquery() + ) + total_count = int((await session.exec(count_statement)).one() or 0) + response.headers["X-Total-Count"] = str(total_count) + response.headers["X-Limit"] = str(limit) + response.headers["X-Offset"] = str(offset) + + ordered_query = skills_query.order_by(col(MarketplaceSkill.created_at).desc()) + if limit is not None: + ordered_query = ordered_query.offset(offset).limit(limit) + skills = await ordered_query.all(session) installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all( session ) diff --git a/backend/app/main.py b/backend/app/main.py index fa0e0164..8de746cf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -103,6 +103,7 @@ if origins: allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Total-Count", "X-Limit", "X-Offset"], ) logger.info("app.cors.enabled origins_count=%s", len(origins)) else: diff --git a/frontend/src/app/skills/marketplace/page.tsx b/frontend/src/app/skills/marketplace/page.tsx index 770fe28f..98b378a0 100644 --- a/frontend/src/app/skills/marketplace/page.tsx +++ b/frontend/src/app/skills/marketplace/page.tsx @@ -4,7 +4,7 @@ export const dynamic = "force-dynamic"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/auth/clerk"; import { useQueryClient } from "@tanstack/react-query"; @@ -29,7 +29,7 @@ import { 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 { Button, buttonVariants } from "@/components/ui/button"; import { useOrganizationMembership } from "@/lib/use-organization-membership"; import { useUrlSorting } from "@/lib/use-url-sorting"; import { Input } from "@/components/ui/input"; @@ -48,14 +48,18 @@ const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [ "source", "updated_at", ]; +const MARKETPLACE_DEFAULT_PAGE_SIZE = 25; +const MARKETPLACE_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const; -type MarketplaceSkillListParams = - listMarketplaceSkillsApiV1SkillsMarketplaceGetParams & { - search?: string; - category?: string; - risk?: string; - pack_id?: string; - }; +type MarketplaceSkillListParams = { + gateway_id: string; + search?: string; + category?: string; + risk?: string; + pack_id?: string; + limit?: number; + offset?: number; +}; const RISK_SORT_ORDER: Record = { safe: 10, @@ -118,8 +122,33 @@ function formatCategoryLabel(category: string) { .join(" "); } +function parsePositiveIntParam(value: string | null, fallback: number) { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + return fallback; + } + return parsed; +} + +function parsePageSizeParam(value: string | null) { + const parsed = parsePositiveIntParam(value, MARKETPLACE_DEFAULT_PAGE_SIZE); + if ( + MARKETPLACE_PAGE_SIZE_OPTIONS.includes( + parsed as (typeof MARKETPLACE_PAGE_SIZE_OPTIONS)[number], + ) + ) { + return parsed; + } + return MARKETPLACE_DEFAULT_PAGE_SIZE; +} + export default function SkillsMarketplacePage() { const queryClient = useQueryClient(); + const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const { isSignedIn } = useAuth(); const { isAdmin } = useOrganizationMembership(isSignedIn); @@ -137,9 +166,20 @@ export default function SkillsMarketplacePage() { const [installingGatewayId, setInstallingGatewayId] = useState( null, ); - const [searchTerm, setSearchTerm] = useState(""); - const [selectedCategory, setSelectedCategory] = useState("all"); - const [selectedRisk, setSelectedRisk] = useState("safe"); + const initialSearch = searchParams.get("search") ?? ""; + const initialCategory = (searchParams.get("category") ?? "all") + .trim() + .toLowerCase(); + const initialRisk = (searchParams.get("risk") ?? "safe").trim().toLowerCase(); + const initialPage = parsePositiveIntParam(searchParams.get("page"), 1); + const initialPageSize = parsePageSizeParam(searchParams.get("limit")); + const [searchTerm, setSearchTerm] = useState(initialSearch); + const [selectedCategory, setSelectedCategory] = useState( + initialCategory || "all", + ); + const [selectedRisk, setSelectedRisk] = useState(initialRisk || "safe"); + const [currentPage, setCurrentPage] = useState(initialPage); + const [pageSize, setPageSize] = useState(initialPageSize); const { sorting, onSortingChange } = useUrlSorting({ allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS, @@ -180,6 +220,8 @@ export default function SkillsMarketplacePage() { const skillsParams = useMemo(() => { const params: MarketplaceSkillListParams = { gateway_id: resolvedGatewayId, + limit: pageSize, + offset: (currentPage - 1) * pageSize, }; if (normalizedSearch) { params.search = normalizedSearch; @@ -194,7 +236,15 @@ export default function SkillsMarketplacePage() { params.pack_id = selectedPackId; } return params; - }, [normalizedCategory, normalizedRisk, normalizedSearch, resolvedGatewayId, selectedPackId]); + }, [ + currentPage, + pageSize, + normalizedCategory, + normalizedRisk, + normalizedSearch, + resolvedGatewayId, + selectedPackId, + ]); const filterOptionsParams = useMemo(() => { const params: MarketplaceSkillListParams = { gateway_id: resolvedGatewayId, @@ -267,6 +317,41 @@ export default function SkillsMarketplacePage() { ); const filteredSkills = useMemo(() => skills, [skills]); + const totalCountInfo = useMemo(() => { + if (skillsQuery.data?.status !== 200) { + return { hasKnownTotal: false, total: skills.length }; + } + const totalCountHeader = skillsQuery.data.headers.get("x-total-count"); + if (typeof totalCountHeader === "string" && totalCountHeader.trim() !== "") { + const parsed = Number(totalCountHeader); + if (Number.isFinite(parsed) && parsed >= 0) { + return { hasKnownTotal: true, total: parsed }; + } + } + return { hasKnownTotal: false, total: skills.length }; + }, [skills, skillsQuery.data]); + const totalSkills = useMemo(() => { + if (totalCountInfo.hasKnownTotal) { + return totalCountInfo.total; + } + return (currentPage - 1) * pageSize + skills.length; + }, [currentPage, pageSize, skills.length, totalCountInfo]); + const totalPages = useMemo( + () => Math.max(1, Math.ceil(totalSkills / pageSize)), + [pageSize, totalSkills], + ); + const hasNextPage = useMemo(() => { + if (totalCountInfo.hasKnownTotal) { + return currentPage < totalPages; + } + return skills.length === pageSize; + }, [currentPage, pageSize, skills.length, totalCountInfo.hasKnownTotal, totalPages]); + const rangeStart = + totalSkills === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const rangeEnd = + totalSkills === 0 + ? 0 + : (currentPage - 1) * pageSize + skills.length; const categoryFilterOptions = useMemo(() => { const byValue = new Map(); @@ -330,6 +415,74 @@ export default function SkillsMarketplacePage() { } }, [riskFilterOptions, selectedRisk]); + useEffect(() => { + setCurrentPage(1); + }, [ + normalizedCategory, + normalizedRisk, + normalizedSearch, + pageSize, + resolvedGatewayId, + selectedPackId, + ]); + + useEffect(() => { + if (totalCountInfo.hasKnownTotal && currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [currentPage, totalCountInfo.hasKnownTotal, totalPages]); + + useEffect(() => { + const nextParams = new URLSearchParams(searchParams.toString()); + const normalizedSearchForUrl = searchTerm.trim(); + if (normalizedSearchForUrl) { + nextParams.set("search", normalizedSearchForUrl); + } else { + nextParams.delete("search"); + } + + if (selectedCategory !== "all") { + nextParams.set("category", selectedCategory); + } else { + nextParams.delete("category"); + } + + if (selectedRisk !== "safe") { + nextParams.set("risk", selectedRisk); + } else { + nextParams.delete("risk"); + } + + if (pageSize !== MARKETPLACE_DEFAULT_PAGE_SIZE) { + nextParams.set("limit", String(pageSize)); + } else { + nextParams.delete("limit"); + } + + if (currentPage > 1) { + nextParams.set("page", String(currentPage)); + } else { + nextParams.delete("page"); + } + + const currentQuery = searchParams.toString(); + const nextQuery = nextParams.toString(); + if (nextQuery !== currentQuery) { + router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { + scroll: false, + }); + } + }, [ + currentPage, + pathname, + pageSize, + router, + searchParams, + searchTerm, + selectedCategory, + selectedRisk, + ]); + 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 @@ -600,11 +753,11 @@ export default function SkillsMarketplacePage() { title="Skills Marketplace" description={ selectedPack - ? `${filteredSkills.length} skill${ - filteredSkills.length === 1 ? "" : "s" + ? `${totalSkills} skill${ + totalSkills === 1 ? "" : "s" } for ${selectedPack.name}.` - : `${filteredSkills.length} skill${ - filteredSkills.length === 1 ? "" : "s" + : `${totalSkills} skill${ + totalSkills === 1 ? "" : "s" } synced from packs.` } isAdmin={isAdmin} @@ -720,6 +873,76 @@ export default function SkillsMarketplacePage() { }} /> +
+
+

+ Showing {rangeStart}-{rangeEnd} of {totalSkills} +

+
+ + Rows + + +
+
+
+ + + {totalCountInfo.hasKnownTotal + ? `Page ${currentPage} of ${totalPages}` + : `Page ${currentPage}`} + + +
+
)}