mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-20 08:15:22 +00:00
fix(web): Improve /repos page performance (#677)
* fix * wip * changelog
This commit is contained in:
parent
92d8abbbf2
commit
a52e97a54c
8 changed files with 575 additions and 131 deletions
|
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### 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)
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -258,6 +258,11 @@ export class RepoIndexManager {
|
|||
},
|
||||
data: {
|
||||
status: RepoIndexingJobStatus.IN_PROGRESS,
|
||||
repo: {
|
||||
update: {
|
||||
latestIndexingJobStatus: RepoIndexingJobStatus.IN_PROGRESS,
|
||||
}
|
||||
}
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
|
|
@ -462,6 +467,11 @@ export class RepoIndexManager {
|
|||
data: {
|
||||
status: RepoIndexingJobStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
repo: {
|
||||
update: {
|
||||
latestIndexingJobStatus: RepoIndexingJobStatus.COMPLETED,
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
repo: true,
|
||||
|
|
@ -522,6 +532,11 @@ export class RepoIndexManager {
|
|||
status: RepoIndexingJobStatus.FAILED,
|
||||
completedAt: new Date(),
|
||||
errorMessage: job.failedReason,
|
||||
repo: {
|
||||
update: {
|
||||
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
|
||||
}
|
||||
}
|
||||
},
|
||||
select: { repo: true }
|
||||
});
|
||||
|
|
@ -550,6 +565,11 @@ export class RepoIndexManager {
|
|||
status: RepoIndexingJobStatus.FAILED,
|
||||
completedAt: new Date(),
|
||||
errorMessage: 'Job stalled',
|
||||
repo: {
|
||||
update: {
|
||||
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
|
||||
}
|
||||
}
|
||||
},
|
||||
select: { repo: true, type: true }
|
||||
});
|
||||
|
|
@ -572,6 +592,11 @@ export class RepoIndexManager {
|
|||
status: RepoIndexingJobStatus.FAILED,
|
||||
completedAt: new Date(),
|
||||
errorMessage: 'Job timed out',
|
||||
repo: {
|
||||
update: {
|
||||
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
|
||||
}
|
||||
}
|
||||
},
|
||||
select: { repo: true }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Repo" ADD COLUMN "latestIndexingJobStatus" "RepoIndexingJobStatus";
|
||||
|
|
@ -66,6 +66,7 @@ model Repo {
|
|||
jobs RepoIndexingJob[]
|
||||
indexedAt DateTime? /// When the repo was last indexed successfully.
|
||||
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_codeHostType CodeHostType /// The type of the external service (e.g., github, gitlab, etc.)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Script } from "../scriptRunner";
|
||||
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 = {
|
||||
run: async (prisma: PrismaClient) => {
|
||||
|
|
@ -32,30 +34,60 @@ export const injectRepoData: Script = {
|
|||
});
|
||||
|
||||
|
||||
console.log(`Creating ${NUM_REPOS} repos...`);
|
||||
console.log(`Creating ${NUM_REPOS} repos...`);
|
||||
|
||||
for (let i = 0; i < NUM_REPOS; i++) {
|
||||
await prisma.repo.create({
|
||||
data: {
|
||||
name: `test-repo-${i}`,
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
metadata: {},
|
||||
cloneUrl: `https://github.com/test-org/test-repo-${i}`,
|
||||
webUrl: `https://github.com/test-org/test-repo-${i}`,
|
||||
orgId,
|
||||
external_id: `test-repo-${i}`,
|
||||
external_codeHostType: 'github',
|
||||
external_codeHostUrl: 'https://github.com',
|
||||
connections: {
|
||||
create: {
|
||||
connectionId: connection.id,
|
||||
}
|
||||
const statuses = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'] as const;
|
||||
const indexingJobTypes = ['INDEX', 'CLEANUP'] as const;
|
||||
|
||||
for (let i = 0; i < NUM_REPOS; i++) {
|
||||
const repo = await prisma.repo.create({
|
||||
data: {
|
||||
name: `test-repo-${i}`,
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
metadata: {},
|
||||
cloneUrl: `https://github.com/test-org/test-repo-${i}`,
|
||||
webUrl: `https://github.com/test-org/test-repo-${i}`,
|
||||
orgId,
|
||||
external_id: `test-repo-${i}`,
|
||||
external_codeHostType: 'github',
|
||||
external_codeHostUrl: 'https://github.com',
|
||||
connections: {
|
||||
create: {
|
||||
connectionId: connection.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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.`);
|
||||
}
|
||||
};
|
||||
|
|
@ -10,35 +10,31 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
||||
import { cn, getCodeHostCommitUrl, getCodeHostIcon, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils"
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
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 Link from "next/link"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
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 { DisplayDate } from "../../components/DisplayDate"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { NotificationDot } from "../../components/notificationDot"
|
||||
import { CodeHostType } from "@sourcebot/db"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
// @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>
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Repo>[] = [
|
||||
interface ColumnsContext {
|
||||
onSortChange: (sortBy: string) => void;
|
||||
currentSortBy?: string;
|
||||
currentSortOrder: string;
|
||||
}
|
||||
|
||||
export const getColumns = (context: ColumnsContext): ColumnDef<Repo>[] => [
|
||||
{
|
||||
accessorKey: "displayName",
|
||||
size: 400,
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
const isActive = context.currentSortBy === 'displayName';
|
||||
const Icon = isActive
|
||||
? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown)
|
||||
: ArrowUpDown;
|
||||
|
||||
return (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => context.onSortChange('displayName')}
|
||||
>
|
||||
Repository
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
|
|
@ -159,14 +169,19 @@ export const columns: ColumnDef<Repo>[] = [
|
|||
{
|
||||
accessorKey: "indexedAt",
|
||||
size: 200,
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
const isActive = context.currentSortBy === 'indexedAt';
|
||||
const Icon = isActive
|
||||
? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown)
|
||||
: ArrowUpDown;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
onClick={() => context.onSortChange('indexedAt')}
|
||||
>
|
||||
Last synced
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
|
|
@ -271,46 +286,118 @@ export const columns: ColumnDef<Repo>[] = [
|
|||
},
|
||||
]
|
||||
|
||||
export const ReposTable = ({ data }: { data: Repo[] }) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
interface ReposTableProps {
|
||||
data: Repo[];
|
||||
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 [rowSelection, setRowSelection] = useState({})
|
||||
const [searchValue, setSearchValue] = useState(initialSearch)
|
||||
const [isPendingSearch, setIsPendingSearch] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
numCompleted,
|
||||
numInProgress,
|
||||
numPending,
|
||||
numFailed,
|
||||
numNoJobs,
|
||||
} = useMemo(() => {
|
||||
return {
|
||||
numCompleted: data.filter((repo) => repo.latestJobStatus === "COMPLETED").length,
|
||||
numInProgress: data.filter((repo) => repo.latestJobStatus === "IN_PROGRESS").length,
|
||||
numPending: data.filter((repo) => repo.latestJobStatus === "PENDING").length,
|
||||
numFailed: data.filter((repo) => repo.latestJobStatus === "FAILED").length,
|
||||
numNoJobs: data.filter((repo) => repo.latestJobStatus === null).length,
|
||||
// Focus search box when '/' is pressed
|
||||
useHotkeys('/', (event) => {
|
||||
event.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
});
|
||||
|
||||
// Debounced search effect - only runs when searchValue changes
|
||||
useEffect(() => {
|
||||
setIsPendingSearch(true);
|
||||
const timer = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (searchValue) {
|
||||
params.set('search', searchValue);
|
||||
} else {
|
||||
params.delete('search');
|
||||
}
|
||||
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);
|
||||
}
|
||||
}, [data]);
|
||||
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({
|
||||
data,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
columnResizeMode: 'onChange',
|
||||
enableColumnResizing: false,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
|
|
@ -319,28 +406,34 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
|
|||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<Input
|
||||
placeholder="Filter repositories..."
|
||||
value={(table.getColumn("displayName")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("displayName")?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<InputGroup className="max-w-sm">
|
||||
<InputGroupInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Filter repositories..."
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
className="ring-0"
|
||||
/>
|
||||
{isPendingSearch && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<Select
|
||||
value={(table.getColumn("latestJobStatus")?.getFilterValue() as string) ?? "all"}
|
||||
onValueChange={(value) => {
|
||||
table.getColumn("latestJobStatus")?.setFilterValue(value === "all" ? "" : value)
|
||||
}}
|
||||
value={initialStatus}
|
||||
onValueChange={updateStatusFilter}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Filter by status</SelectItem>
|
||||
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
|
||||
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
|
||||
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
|
||||
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
|
||||
<SelectItem value="null">No status ({numNoJobs})</SelectItem>
|
||||
<SelectItem value="COMPLETED">Completed ({stats.numCompleted})</SelectItem>
|
||||
<SelectItem value="IN_PROGRESS">In progress ({stats.numInProgress})</SelectItem>
|
||||
<SelectItem value="PENDING">Pending ({stats.numPending})</SelectItem>
|
||||
<SelectItem value="FAILED">Failed ({stats.numFailed})</SelectItem>
|
||||
<SelectItem value="none">No status ({stats.numNoJobs})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
|
|
@ -401,18 +494,32 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
|
|||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<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 className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('page', String(currentPage - 1));
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Previous
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,30 +3,49 @@ import { ServiceErrorException } from "@/lib/serviceError";
|
|||
import { isServiceError } from "@/lib/utils";
|
||||
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||
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();
|
||||
if (isServiceError(_repos)) {
|
||||
throw new ServiceErrorException(_repos);
|
||||
export default async function ReposPage({ searchParams }: ReposPageProps) {
|
||||
const params = await searchParams;
|
||||
|
||||
// Parse pagination parameters with defaults
|
||||
const page = z.number().int().positive().safeParse(params.page).data ?? 1;
|
||||
const pageSize = z.number().int().positive().safeParse(params.pageSize).data ?? 5;
|
||||
|
||||
// Parse filter parameters
|
||||
const search = z.string().optional().safeParse(params.search).data ?? '';
|
||||
const status = z.enum(['all', 'none', 'COMPLETED', 'IN_PROGRESS', 'PENDING', 'FAILED']).safeParse(params.status).data ?? 'all';
|
||||
const sortBy = z.enum(['displayName', 'indexedAt']).safeParse(params.sortBy).data ?? undefined;
|
||||
const sortOrder = z.enum(['asc', 'desc']).safeParse(params.sortOrder).data ?? 'asc';
|
||||
|
||||
// Calculate skip for pagination
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const _result = await getRepos({
|
||||
skip,
|
||||
take: pageSize,
|
||||
search,
|
||||
status,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
if (isServiceError(_result)) {
|
||||
throw new ServiceErrorException(_result);
|
||||
}
|
||||
|
||||
const repos = _repos
|
||||
.map((repo) => ({
|
||||
...repo,
|
||||
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,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isFirstTimeIndex && !b.isFirstTimeIndex) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.isFirstTimeIndex && b.isFirstTimeIndex) {
|
||||
return 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const { repos, totalCount, stats } = _result;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -34,42 +53,129 @@ export default async function ReposPage() {
|
|||
<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>
|
||||
</div>
|
||||
<ReposTable data={repos.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName ?? repo.name,
|
||||
isArchived: repo.isArchived,
|
||||
isPublic: repo.isPublic,
|
||||
indexedAt: repo.indexedAt,
|
||||
createdAt: repo.createdAt,
|
||||
webUrl: repo.webUrl,
|
||||
imageUrl: repo.imageUrl,
|
||||
latestJobStatus: repo.latestJobStatus,
|
||||
isFirstTimeIndex: repo.isFirstTimeIndex,
|
||||
codeHostType: repo.external_codeHostType,
|
||||
indexedCommitHash: repo.indexedCommitHash,
|
||||
}))} />
|
||||
<ReposTable
|
||||
data={repos.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName ?? repo.name,
|
||||
isArchived: repo.isArchived,
|
||||
isPublic: repo.isPublic,
|
||||
indexedAt: repo.indexedAt,
|
||||
createdAt: repo.createdAt,
|
||||
webUrl: repo.webUrl,
|
||||
imageUrl: repo.imageUrl,
|
||||
latestJobStatus: repo.latestIndexingJobStatus,
|
||||
isFirstTimeIndex: repo.indexedAt === null,
|
||||
codeHostType: repo.external_codeHostType,
|
||||
indexedCommitHash: repo.indexedCommitHash,
|
||||
}))}
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
initialSearch={search}
|
||||
initialStatus={status}
|
||||
initialSortBy={sortBy}
|
||||
initialSortOrder={sortOrder}
|
||||
stats={stats}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const getReposWithLatestJob = async () => sew(() =>
|
||||
withOptionalAuthV2(async ({ prisma, org }) => {
|
||||
interface GetReposParams {
|
||||
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({
|
||||
include: {
|
||||
jobs: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 1
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: {
|
||||
orgId: org.id,
|
||||
}
|
||||
skip,
|
||||
take,
|
||||
where: whereClause,
|
||||
orderBy: orderByClause,
|
||||
});
|
||||
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,
|
||||
}
|
||||
};
|
||||
}));
|
||||
170
packages/web/src/components/ui/input-group.tsx
Normal file
170
packages/web/src/components/ui/input-group.tsx
Normal 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,
|
||||
}
|
||||
Loading…
Reference in a new issue