feat(skills): implement pagination and total count headers for marketplace skills
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [selectedRisk, setSelectedRisk] = useState<string>("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<string>(
|
||||
initialCategory || "all",
|
||||
);
|
||||
const [selectedRisk, setSelectedRisk] = useState<string>(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<MarketplaceSkillListParams>(() => {
|
||||
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<MarketplaceSkillListParams>(() => {
|
||||
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<string, string>();
|
||||
@@ -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() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<p>
|
||||
Showing {rangeStart}-{rangeEnd} of {totalSkills}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Rows
|
||||
</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
const next = Number.parseInt(value, 10);
|
||||
if (
|
||||
MARKETPLACE_PAGE_SIZE_OPTIONS.includes(
|
||||
next as (typeof MARKETPLACE_PAGE_SIZE_OPTIONS)[number],
|
||||
)
|
||||
) {
|
||||
setPageSize(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="marketplace-footer-limit-filter"
|
||||
className="h-8 w-24"
|
||||
>
|
||||
<SelectValue placeholder="25" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MARKETPLACE_PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1 || skillsQuery.isLoading}
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{totalCountInfo.hasKnownTotal
|
||||
? `Page ${currentPage} of ${totalPages}`
|
||||
: `Page ${currentPage}`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!hasNextPage || skillsQuery.isLoading}
|
||||
onClick={() => {
|
||||
setCurrentPage((prev) =>
|
||||
totalCountInfo.hasKnownTotal
|
||||
? Math.min(totalPages, prev + 1)
|
||||
: prev + 1,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user