fix(web): Improve /repos page performance (#677)

* fix

* wip

* changelog
This commit is contained in:
Brendan Kellam 2025-12-18 13:45:11 -05:00 committed by GitHub
parent 92d8abbbf2
commit a52e97a54c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 575 additions and 131 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed issue where parenthesis in query params were not being encoded, resulting in a poor experience when embedding links in Markdown. [#674](https://github.com/sourcebot-dev/sourcebot/pull/674) - Fixed issue where parenthesis in query params were not being encoded, resulting in a poor experience when embedding links in Markdown. [#674](https://github.com/sourcebot-dev/sourcebot/pull/674)
- Gitlab clone respects host protocol setting in environment variable. [#676](https://github.com/sourcebot-dev/sourcebot/pull/676) - Gitlab clone respects host protocol setting in environment variable. [#676](https://github.com/sourcebot-dev/sourcebot/pull/676)
- Fixed performance issues with `/repos` page. [#677](https://github.com/sourcebot-dev/sourcebot/pull/677)
## [4.10.3] - 2025-12-12 ## [4.10.3] - 2025-12-12

View file

@ -258,6 +258,11 @@ export class RepoIndexManager {
}, },
data: { data: {
status: RepoIndexingJobStatus.IN_PROGRESS, status: RepoIndexingJobStatus.IN_PROGRESS,
repo: {
update: {
latestIndexingJobStatus: RepoIndexingJobStatus.IN_PROGRESS,
}
}
}, },
select: { select: {
type: true, type: true,
@ -462,6 +467,11 @@ export class RepoIndexManager {
data: { data: {
status: RepoIndexingJobStatus.COMPLETED, status: RepoIndexingJobStatus.COMPLETED,
completedAt: new Date(), completedAt: new Date(),
repo: {
update: {
latestIndexingJobStatus: RepoIndexingJobStatus.COMPLETED,
}
}
}, },
include: { include: {
repo: true, repo: true,
@ -522,6 +532,11 @@ export class RepoIndexManager {
status: RepoIndexingJobStatus.FAILED, status: RepoIndexingJobStatus.FAILED,
completedAt: new Date(), completedAt: new Date(),
errorMessage: job.failedReason, errorMessage: job.failedReason,
repo: {
update: {
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
}
}
}, },
select: { repo: true } select: { repo: true }
}); });
@ -550,6 +565,11 @@ export class RepoIndexManager {
status: RepoIndexingJobStatus.FAILED, status: RepoIndexingJobStatus.FAILED,
completedAt: new Date(), completedAt: new Date(),
errorMessage: 'Job stalled', errorMessage: 'Job stalled',
repo: {
update: {
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
}
}
}, },
select: { repo: true, type: true } select: { repo: true, type: true }
}); });
@ -572,6 +592,11 @@ export class RepoIndexManager {
status: RepoIndexingJobStatus.FAILED, status: RepoIndexingJobStatus.FAILED,
completedAt: new Date(), completedAt: new Date(),
errorMessage: 'Job timed out', errorMessage: 'Job timed out',
repo: {
update: {
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
}
}
}, },
select: { repo: true } select: { repo: true }
}); });

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Repo" ADD COLUMN "latestIndexingJobStatus" "RepoIndexingJobStatus";

View file

@ -66,6 +66,7 @@ model Repo {
jobs RepoIndexingJob[] jobs RepoIndexingJob[]
indexedAt DateTime? /// When the repo was last indexed successfully. indexedAt DateTime? /// When the repo was last indexed successfully.
indexedCommitHash String? /// The commit hash of the last indexed commit (on HEAD). indexedCommitHash String? /// The commit hash of the last indexed commit (on HEAD).
latestIndexingJobStatus RepoIndexingJobStatus? /// The status of the latest indexing job.
external_id String /// The id of the repo in the external service external_id String /// The id of the repo in the external service
external_codeHostType CodeHostType /// The type of the external service (e.g., github, gitlab, etc.) external_codeHostType CodeHostType /// The type of the external service (e.g., github, gitlab, etc.)

View file

@ -1,7 +1,9 @@
import { Script } from "../scriptRunner"; import { Script } from "../scriptRunner";
import { PrismaClient } from "../../dist"; import { PrismaClient } from "../../dist";
const NUM_REPOS = 100000; const NUM_REPOS = 1000;
const NUM_INDEXING_JOBS_PER_REPO = 10000;
const NUM_PERMISSION_JOBS_PER_REPO = 10000;
export const injectRepoData: Script = { export const injectRepoData: Script = {
run: async (prisma: PrismaClient) => { run: async (prisma: PrismaClient) => {
@ -34,8 +36,11 @@ export const injectRepoData: Script = {
console.log(`Creating ${NUM_REPOS} repos...`); console.log(`Creating ${NUM_REPOS} repos...`);
const statuses = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'] as const;
const indexingJobTypes = ['INDEX', 'CLEANUP'] as const;
for (let i = 0; i < NUM_REPOS; i++) { for (let i = 0; i < NUM_REPOS; i++) {
await prisma.repo.create({ const repo = await prisma.repo.create({
data: { data: {
name: `test-repo-${i}`, name: `test-repo-${i}`,
isFork: false, isFork: false,
@ -54,8 +59,35 @@ export const injectRepoData: Script = {
} }
} }
}); });
for (let j = 0; j < NUM_PERMISSION_JOBS_PER_REPO; j++) {
const status = statuses[Math.floor(Math.random() * statuses.length)];
await prisma.repoPermissionSyncJob.create({
data: {
repoId: repo.id,
status,
completedAt: status === 'COMPLETED' || status === 'FAILED' ? new Date() : null,
errorMessage: status === 'FAILED' ? 'Mock error message' : null
}
});
} }
console.log(`Created ${NUM_REPOS} repos.`); for (let j = 0; j < NUM_INDEXING_JOBS_PER_REPO; j++) {
const status = statuses[Math.floor(Math.random() * statuses.length)];
const type = indexingJobTypes[Math.floor(Math.random() * indexingJobTypes.length)];
await prisma.repoIndexingJob.create({
data: {
repoId: repo.id,
type,
status,
completedAt: status === 'COMPLETED' || status === 'FAILED' ? new Date() : null,
errorMessage: status === 'FAILED' ? 'Mock indexing error' : null,
metadata: {}
}
});
}
}
console.log(`Created ${NUM_REPOS} repos with associated jobs.`);
} }
}; };

View file

@ -10,35 +10,31 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { cn, getCodeHostCommitUrl, getCodeHostIcon, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils" import { cn, getCodeHostCommitUrl, getCodeHostIcon, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils"
import { import {
type ColumnDef, type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState, type VisibilityState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority"
import { ArrowUpDown, ExternalLink, MoreHorizontal, RefreshCwIcon } from "lucide-react" import { ArrowDown, ArrowUp, ArrowUpDown, ExternalLink, Loader2, MoreHorizontal, RefreshCwIcon } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useMemo, useState } from "react" import { useEffect, useRef, useState } from "react"
import { getBrowsePath } from "../../browse/hooks/utils" import { getBrowsePath } from "../../browse/hooks/utils"
import { useRouter } from "next/navigation" import { useRouter, useSearchParams, usePathname } from "next/navigation"
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { DisplayDate } from "../../components/DisplayDate" import { DisplayDate } from "../../components/DisplayDate"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { NotificationDot } from "../../components/notificationDot" import { NotificationDot } from "../../components/notificationDot"
import { CodeHostType } from "@sourcebot/db" import { CodeHostType } from "@sourcebot/db"
import { useHotkeys } from "react-hotkeys-hook"
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS // @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
@ -84,15 +80,29 @@ const getStatusBadge = (status: Repo["latestJobStatus"]) => {
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge> return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
} }
export const columns: ColumnDef<Repo>[] = [ interface ColumnsContext {
onSortChange: (sortBy: string) => void;
currentSortBy?: string;
currentSortOrder: string;
}
export const getColumns = (context: ColumnsContext): ColumnDef<Repo>[] => [
{ {
accessorKey: "displayName", accessorKey: "displayName",
size: 400, size: 400,
header: ({ column }) => { header: () => {
const isActive = context.currentSortBy === 'displayName';
const Icon = isActive
? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown)
: ArrowUpDown;
return ( return (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}> <Button
variant="ghost"
onClick={() => context.onSortChange('displayName')}
>
Repository Repository
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
) )
}, },
@ -159,14 +169,19 @@ export const columns: ColumnDef<Repo>[] = [
{ {
accessorKey: "indexedAt", accessorKey: "indexedAt",
size: 200, size: 200,
header: ({ column }) => { header: () => {
const isActive = context.currentSortBy === 'indexedAt';
const Icon = isActive
? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown)
: ArrowUpDown;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => context.onSortChange('indexedAt')}
> >
Last synced Last synced
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
) )
}, },
@ -271,46 +286,118 @@ export const columns: ColumnDef<Repo>[] = [
}, },
] ]
export const ReposTable = ({ data }: { data: Repo[] }) => { interface ReposTableProps {
const [sorting, setSorting] = useState<SortingState>([]) data: Repo[];
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) currentPage: number;
pageSize: number;
totalCount: number;
initialSearch: string;
initialStatus: string;
initialSortBy?: string;
initialSortOrder: string;
stats: {
numCompleted: number
numFailed: number
numPending: number
numInProgress: number
numNoJobs: number
}
}
export const ReposTable = ({
data,
currentPage,
pageSize,
totalCount,
initialSearch,
initialStatus,
initialSortBy,
initialSortOrder,
stats,
}: ReposTableProps) => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({}) const [rowSelection, setRowSelection] = useState({})
const [searchValue, setSearchValue] = useState(initialSearch)
const [isPendingSearch, setIsPendingSearch] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const { toast } = useToast(); const { toast } = useToast();
const { // Focus search box when '/' is pressed
numCompleted, useHotkeys('/', (event) => {
numInProgress, event.preventDefault();
numPending, searchInputRef.current?.focus();
numFailed, });
numNoJobs,
} = useMemo(() => { // Debounced search effect - only runs when searchValue changes
return { useEffect(() => {
numCompleted: data.filter((repo) => repo.latestJobStatus === "COMPLETED").length, setIsPendingSearch(true);
numInProgress: data.filter((repo) => repo.latestJobStatus === "IN_PROGRESS").length, const timer = setTimeout(() => {
numPending: data.filter((repo) => repo.latestJobStatus === "PENDING").length, const params = new URLSearchParams(searchParams.toString());
numFailed: data.filter((repo) => repo.latestJobStatus === "FAILED").length, if (searchValue) {
numNoJobs: data.filter((repo) => repo.latestJobStatus === null).length, params.set('search', searchValue);
} else {
params.delete('search');
} }
}, [data]); params.set('page', '1'); // Reset to page 1 on search
router.replace(`${pathname}?${params.toString()}`);
setIsPendingSearch(false);
}, 300);
return () => {
clearTimeout(timer);
setIsPendingSearch(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue]);
const updateStatusFilter = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value === 'all') {
params.delete('status');
} else {
params.set('status', value);
}
params.set('page', '1'); // Reset to page 1 on filter change
router.replace(`${pathname}?${params.toString()}`);
};
const handleSortChange = (sortBy: string) => {
const params = new URLSearchParams(searchParams.toString());
// Toggle sort order if clicking the same column
if (initialSortBy === sortBy) {
const newOrder = initialSortOrder === 'asc' ? 'desc' : 'asc';
params.set('sortOrder', newOrder);
} else {
// Default to ascending when changing columns
params.set('sortBy', sortBy);
params.set('sortOrder', 'asc');
}
params.set('page', '1'); // Reset to page 1 on sort change
router.replace(`${pathname}?${params.toString()}`);
};
const totalPages = Math.ceil(totalCount / pageSize);
const columns = getColumns({
onSortChange: handleSortChange,
currentSortBy: initialSortBy,
currentSortOrder: initialSortOrder,
});
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
columnResizeMode: 'onChange', columnResizeMode: 'onChange',
enableColumnResizing: false, enableColumnResizing: false,
state: { state: {
sorting,
columnFilters,
columnVisibility, columnVisibility,
rowSelection, rowSelection,
}, },
@ -319,28 +406,34 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
return ( return (
<div className="w-full"> <div className="w-full">
<div className="flex items-center gap-4 py-4"> <div className="flex items-center gap-4 py-4">
<Input <InputGroup className="max-w-sm">
<InputGroupInput
ref={searchInputRef}
placeholder="Filter repositories..." placeholder="Filter repositories..."
value={(table.getColumn("displayName")?.getFilterValue() as string) ?? ""} value={searchValue}
onChange={(event) => table.getColumn("displayName")?.setFilterValue(event.target.value)} onChange={(event) => setSearchValue(event.target.value)}
className="max-w-sm" className="ring-0"
/> />
{isPendingSearch && (
<InputGroupAddon align="inline-end">
<Loader2 className="h-4 w-4 animate-spin" />
</InputGroupAddon>
)}
</InputGroup>
<Select <Select
value={(table.getColumn("latestJobStatus")?.getFilterValue() as string) ?? "all"} value={initialStatus}
onValueChange={(value) => { onValueChange={updateStatusFilter}
table.getColumn("latestJobStatus")?.setFilterValue(value === "all" ? "" : value)
}}
> >
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" /> <SelectValue placeholder="Filter by status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Filter by status</SelectItem> <SelectItem value="all">Filter by status</SelectItem>
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem> <SelectItem value="COMPLETED">Completed ({stats.numCompleted})</SelectItem>
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem> <SelectItem value="IN_PROGRESS">In progress ({stats.numInProgress})</SelectItem>
<SelectItem value="PENDING">Pending ({numPending})</SelectItem> <SelectItem value="PENDING">Pending ({stats.numPending})</SelectItem>
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem> <SelectItem value="FAILED">Failed ({stats.numFailed})</SelectItem>
<SelectItem value="null">No status ({numNoJobs})</SelectItem> <SelectItem value="none">No status ({stats.numNoJobs})</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
@ -401,18 +494,32 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
</div> </div>
<div className="flex items-center justify-end space-x-2 py-4"> <div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground"> <div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} {data.length > 1 ? 'repositories' : 'repository'} total {totalCount} {totalCount !== 1 ? 'repositories' : 'repository'} total
{totalPages > 1 && ` • Page ${currentPage} of ${totalPages}`}
</div> </div>
<div className="space-x-2"> <div className="space-x-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => table.previousPage()} onClick={() => {
disabled={!table.getCanPreviousPage()} const params = new URLSearchParams(searchParams.toString());
params.set('page', String(currentPage - 1));
router.push(`${pathname}?${params.toString()}`);
}}
disabled={currentPage <= 1}
> >
Previous Previous
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}> <Button
variant="outline"
size="sm"
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', String(currentPage + 1));
router.push(`${pathname}?${params.toString()}`);
}}
disabled={currentPage >= totalPages}
>
Next Next
</Button> </Button>
</div> </div>

View file

@ -3,30 +3,49 @@ import { ServiceErrorException } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { withOptionalAuthV2 } from "@/withAuthV2"; import { withOptionalAuthV2 } from "@/withAuthV2";
import { ReposTable } from "./components/reposTable"; import { ReposTable } from "./components/reposTable";
import { RepoIndexingJobStatus } from "@sourcebot/db"; import { RepoIndexingJobStatus, Prisma } from "@sourcebot/db";
import z from "zod";
export default async function ReposPage() { interface ReposPageProps {
searchParams: Promise<{
page?: string;
pageSize?: string;
search?: string;
status?: string;
sortBy?: string;
sortOrder?: string;
}>;
}
const _repos = await getReposWithLatestJob(); export default async function ReposPage({ searchParams }: ReposPageProps) {
if (isServiceError(_repos)) { const params = await searchParams;
throw new ServiceErrorException(_repos);
}
const repos = _repos // Parse pagination parameters with defaults
.map((repo) => ({ const page = z.number().int().positive().safeParse(params.page).data ?? 1;
...repo, const pageSize = z.number().int().positive().safeParse(params.pageSize).data ?? 5;
latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null,
isFirstTimeIndex: repo.indexedAt === null && repo.jobs.filter((job) => job.status === RepoIndexingJobStatus.PENDING || job.status === RepoIndexingJobStatus.IN_PROGRESS).length > 0, // Parse filter parameters
})) const search = z.string().optional().safeParse(params.search).data ?? '';
.sort((a, b) => { const status = z.enum(['all', 'none', 'COMPLETED', 'IN_PROGRESS', 'PENDING', 'FAILED']).safeParse(params.status).data ?? 'all';
if (a.isFirstTimeIndex && !b.isFirstTimeIndex) { const sortBy = z.enum(['displayName', 'indexedAt']).safeParse(params.sortBy).data ?? undefined;
return -1; const sortOrder = z.enum(['asc', 'desc']).safeParse(params.sortOrder).data ?? 'asc';
}
if (!a.isFirstTimeIndex && b.isFirstTimeIndex) { // Calculate skip for pagination
return 1; const skip = (page - 1) * pageSize;
}
return a.name.localeCompare(b.name); const _result = await getRepos({
skip,
take: pageSize,
search,
status,
sortBy,
sortOrder,
}); });
if (isServiceError(_result)) {
throw new ServiceErrorException(_result);
}
const { repos, totalCount, stats } = _result;
return ( return (
<> <>
@ -34,7 +53,8 @@ export default async function ReposPage() {
<h1 className="text-3xl font-semibold">Repositories</h1> <h1 className="text-3xl font-semibold">Repositories</h1>
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p> <p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
</div> </div>
<ReposTable data={repos.map((repo) => ({ <ReposTable
data={repos.map((repo) => ({
id: repo.id, id: repo.id,
name: repo.name, name: repo.name,
displayName: repo.displayName ?? repo.name, displayName: repo.displayName ?? repo.name,
@ -44,32 +64,118 @@ export default async function ReposPage() {
createdAt: repo.createdAt, createdAt: repo.createdAt,
webUrl: repo.webUrl, webUrl: repo.webUrl,
imageUrl: repo.imageUrl, imageUrl: repo.imageUrl,
latestJobStatus: repo.latestJobStatus, latestJobStatus: repo.latestIndexingJobStatus,
isFirstTimeIndex: repo.isFirstTimeIndex, isFirstTimeIndex: repo.indexedAt === null,
codeHostType: repo.external_codeHostType, codeHostType: repo.external_codeHostType,
indexedCommitHash: repo.indexedCommitHash, indexedCommitHash: repo.indexedCommitHash,
}))} /> }))}
currentPage={page}
pageSize={pageSize}
totalCount={totalCount}
initialSearch={search}
initialStatus={status}
initialSortBy={sortBy}
initialSortOrder={sortOrder}
stats={stats}
/>
</> </>
) )
} }
const getReposWithLatestJob = async () => sew(() => interface GetReposParams {
withOptionalAuthV2(async ({ prisma, org }) => { skip: number;
take: number;
search: string;
status: 'all' | 'none' | 'COMPLETED' | 'IN_PROGRESS' | 'PENDING' | 'FAILED';
sortBy?: 'displayName' | 'indexedAt';
sortOrder: 'asc' | 'desc';
}
const getRepos = async ({ skip, take, search, status, sortBy, sortOrder }: GetReposParams) => sew(() =>
withOptionalAuthV2(async ({ prisma }) => {
const whereClause: Prisma.RepoWhereInput = {
...(search ? {
displayName: { contains: search, mode: 'insensitive' },
} : {}),
latestIndexingJobStatus:
status === 'all' ? undefined :
status === 'none' ? null :
status
};
// Build orderBy clause based on sortBy and sortOrder
const orderByClause: Prisma.RepoOrderByWithRelationInput = {};
if (sortBy === 'displayName') {
orderByClause.displayName = sortOrder === 'asc' ? 'asc' : 'desc';
} else if (sortBy === 'indexedAt') {
orderByClause.indexedAt = sortOrder === 'asc' ? 'asc' : 'desc';
} else {
// Default to displayName asc
orderByClause.displayName = 'asc';
}
const repos = await prisma.repo.findMany({ const repos = await prisma.repo.findMany({
include: { skip,
jobs: { take,
orderBy: { where: whereClause,
createdAt: 'desc' orderBy: orderByClause,
},
take: 1
}
},
orderBy: {
name: 'asc'
},
where: {
orgId: org.id,
}
}); });
return repos;
// Calculate total count using the filtered where clause
const totalCount = await prisma.repo.count({
where: whereClause
});
// Status stats
const [
numCompleted,
numFailed,
numPending,
numInProgress,
numNoJobs
] = await Promise.all([
prisma.repo.count({
where: {
...whereClause,
latestIndexingJobStatus: RepoIndexingJobStatus.COMPLETED,
}
}),
prisma.repo.count({
where: {
...whereClause,
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
}
}),
prisma.repo.count({
where: {
...whereClause,
latestIndexingJobStatus: RepoIndexingJobStatus.PENDING,
}
}),
prisma.repo.count({
where: {
...whereClause,
latestIndexingJobStatus: RepoIndexingJobStatus.IN_PROGRESS,
}
}),
prisma.repo.count({
where: {
...whereClause,
latestIndexingJobStatus: null,
}
}),
])
return {
repos,
totalCount,
stats: {
numCompleted,
numFailed,
numPending,
numInProgress,
numNoJobs,
}
};
})); }));

View file

@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input bg-background relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 h-10 px-3 py-2 text-base md:text-sm rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}