diff --git a/packages/web/src/app/[domain]/components/header.tsx b/packages/web/src/app/[domain]/components/header.tsx deleted file mode 100644 index 79a24ee4..00000000 --- a/packages/web/src/app/[domain]/components/header.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/lib/utils"; -import clsx from "clsx"; - -interface HeaderProps { - children: React.ReactNode; - withTopMargin?: boolean; - className?: string; -} - -export const Header = ({ - children, - withTopMargin = true, - className, -}: HeaderProps) => { - return ( -
- {children} - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/[id]/page.tsx b/packages/web/src/app/[domain]/repos/[id]/page.tsx new file mode 100644 index 00000000..4dd48e25 --- /dev/null +++ b/packages/web/src/app/[domain]/repos/[id]/page.tsx @@ -0,0 +1,140 @@ +import { Suspense } from "react" +import { notFound } from "next/navigation" +import Link from "next/link" +import { ChevronLeft, ExternalLink } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { RepoJobsTable } from "../components/repo-jobs-table" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { sew } from "@/actions" +import { withOptionalAuthV2 } from "@/withAuthV2" +import { ServiceErrorException } from "@/lib/serviceError" +import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils" +import Image from "next/image" + +function formatDate(date: Date | null) { + if (!date) return "Never" + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date) +} + +export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const repo = await getRepoWithJobs(Number.parseInt(id)) + if (isServiceError(repo)) { + throw new ServiceErrorException(repo); + } + + const codeHostInfo = getCodeHostInfoForRepo({ + codeHostType: repo.external_codeHostType, + name: repo.name, + displayName: repo.displayName ?? undefined, + webUrl: repo.webUrl ?? undefined, + }); + + return ( +
+
+ + +
+
+

{repo.displayName || repo.name}

+

{repo.name}

+
+ {(codeHostInfo && codeHostInfo.repoLink) && ( + + )} +
+ +
+ {repo.isArchived && Archived} + {repo.isPublic && Public} +
+
+ +
+ + + Last Indexed + + +
{repo.indexedAt ? formatDate(repo.indexedAt) : "Never"}
+
+
+ + + + Created + + +
{formatDate(repo.createdAt)}
+
+
+ + + + Last Updated + + +
{formatDate(repo.updatedAt)}
+
+
+
+ + + + Indexing Jobs + History of all indexing and cleanup jobs for this repository + + + }> + + + + +
+ ) +} + +const getRepoWithJobs = async (repoId: number) => sew(() => + withOptionalAuthV2(async ({ prisma }) => { + + const repo = await prisma.repo.findUnique({ + where: { + id: repoId, + }, + include: { + jobs: true, + } + }); + + if (!repo) { + return notFound(); + } + + return repo; + }) +); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/components/repo-jobs-table.tsx b/packages/web/src/app/[domain]/repos/components/repo-jobs-table.tsx new file mode 100644 index 00000000..5e18c6a3 --- /dev/null +++ b/packages/web/src/app/[domain]/repos/components/repo-jobs-table.tsx @@ -0,0 +1,281 @@ +"use client" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +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 { AlertCircle, ArrowUpDown } from "lucide-react" +import * as React from "react" +import { CopyIconButton } from "../../components/copyIconButton" + +export type RepoIndexingJob = { + id: string + type: "INDEX" | "CLEANUP" + status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" + createdAt: Date + updatedAt: Date + completedAt: Date | null + errorMessage: string | null +} + +const statusBadgeVariants = cva("", { + variants: { + status: { + PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90", + COMPLETED: "bg-green-600 text-white hover:bg-green-700", + FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + }, + }, +}) + +const getStatusBadge = (status: RepoIndexingJob["status"]) => { + const labels = { + PENDING: "Pending", + IN_PROGRESS: "In Progress", + COMPLETED: "Completed", + FAILED: "Failed", + } + + return {labels[status]} +} + +const getTypeBadge = (type: RepoIndexingJob["type"]) => { + return ( + + {type} + + ) +} + +const formatDate = (date: Date | null) => { + if (!date) return "-" + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date) +} + +const getDuration = (start: Date, end: Date | null) => { + if (!end) return "-" + const diff = end.getTime() - start.getTime() + const minutes = Math.floor(diff / 60000) + const seconds = Math.floor((diff % 60000) / 1000) + return `${minutes}m ${seconds}s` +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "type", + header: "Type", + cell: ({ row }) => getTypeBadge(row.getValue("type")), + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const job = row.original + return ( +
+ {getStatusBadge(row.getValue("status"))} + {job.errorMessage && ( + + + + + + +

{job.errorMessage}

+
+
+
+ )} +
+ ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => formatDate(row.getValue("createdAt")), + }, + { + accessorKey: "completedAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => formatDate(row.getValue("completedAt")), + }, + { + id: "duration", + header: "Duration", + cell: ({ row }) => { + const job = row.original + return getDuration(job.createdAt, job.completedAt) + }, + }, + { + accessorKey: "id", + header: "Job ID", + cell: ({ row }) => { + const id = row.getValue("id") as string + return ( +
+ {id} + { + navigator.clipboard.writeText(id); + return true; + }} /> +
+ ) + }, + }, +] + +export const RepoJobsTable = ({ data }: { data: RepoIndexingJob[] }) => { + const [sorting, setSorting] = React.useState([{ id: "createdAt", desc: true }]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + sorting, + columnFilters, + columnVisibility, + }, + }) + + return ( +
+
+ + + +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No indexing jobs found. + + + )} + +
+
+ +
+
+ {table.getFilteredRowModel().rows.length} job(s) total +
+
+ + +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/repos/components/repos-table.tsx b/packages/web/src/app/[domain]/repos/components/repos-table.tsx new file mode 100644 index 00000000..b7265af0 --- /dev/null +++ b/packages/web/src/app/[domain]/repos/components/repos-table.tsx @@ -0,0 +1,283 @@ +"use client" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +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 { 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 } from "lucide-react" +import Image from "next/image" +import Link from "next/link" +import * as React from "react" + +export type Repo = { + id: number + name: string + displayName: string | null + isArchived: boolean + isPublic: boolean + indexedAt: Date | null + createdAt: Date + webUrl: string | null + imageUrl: string | null + latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null +} + +const statusBadgeVariants = cva("", { + variants: { + status: { + PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90", + COMPLETED: "bg-green-600 text-white hover:bg-green-700", + FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + NO_JOBS: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + }, + }, +}) + +const getStatusBadge = (status: Repo["latestJobStatus"]) => { + if (!status) { + return No Jobs + } + + const labels = { + PENDING: "Pending", + IN_PROGRESS: "In Progress", + COMPLETED: "Completed", + FAILED: "Failed", + } + + return {labels[status]} +} + +const formatDate = (date: Date | null) => { + if (!date) return "Never" + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date) +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "displayName", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const repo = row.original + return ( +
+ {repo.imageUrl ? ( + {`${repo.displayName} + ) : ( +
+ {repo.displayName?.charAt(0) ?? repo.name.charAt(0)} +
+ )} + + {repo.displayName || repo.name} + +
+ ) + }, + }, + { + accessorKey: "latestJobStatus", + header: "Status", + cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")), + }, + { + accessorKey: "indexedAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => formatDate(row.getValue("indexedAt")), + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const repo = row.original + + return ( + + + + + + Actions + + View details + + {repo.webUrl && ( + <> + + + + Open in GitHub + + + + + )} + + + ) + }, + }, +] + +export const ReposTable = ({ data }: { data: Repo[] }) => { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [rowSelection, setRowSelection] = React.useState({}) + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }) + + return ( +
+
+ table.getColumn("displayName")?.setFilterValue(event.target.value)} + className="max-w-sm" + /> + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ {table.getFilteredRowModel().rows.length} {data.length > 1 ? 'repositories' : 'repository'} total +
+
+ + +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index e9dd4e43..6769f119 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -1,65 +1,49 @@ -import { env } from "@/env.mjs"; -import { RepoIndexingJob } from "@sourcebot/db"; -import { Header } from "../components/header"; -import { RepoStatus } from "./columns"; -import { RepositoryTable } from "./repositoryTable"; import { sew } from "@/actions"; -import { withOptionalAuthV2 } from "@/withAuthV2"; -import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { withOptionalAuthV2 } from "@/withAuthV2"; +import { ReposTable } from "./components/repos-table"; -function getRepoStatus(repo: { indexedAt: Date | null, jobs: RepoIndexingJob[] }): RepoStatus { - const latestJob = repo.jobs[0]; - - if (latestJob?.status === 'PENDING' || latestJob?.status === 'IN_PROGRESS') { - return 'syncing'; - } - - return repo.indexedAt ? 'indexed' : 'not-indexed'; -} +export default async function ReposPage() { -export default async function ReposPage(props: { params: Promise<{ domain: string }> }) { - const params = await props.params; - - const { - domain - } = params; - - const repos = await getReposWithJobs(); + const repos = await getReposWithLatestJob(); if (isServiceError(repos)) { throw new ServiceErrorException(repos); } return ( -
-
-

Repositories

-
-
- ({ - repoId: repo.id, - repoName: repo.name, - repoDisplayName: repo.displayName ?? repo.name, - imageUrl: repo.imageUrl ?? undefined, - indexedAt: repo.indexedAt ?? undefined, - status: getRepoStatus(repo), - }))} - domain={domain} - isAddReposButtonVisible={env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED === 'true'} - /> +
+
+

Repositories

+

View and manage your code repositories and their indexing status.

+ ({ + 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.jobs.length > 0 ? repo.jobs[0].status : null + }))} />
) } -const getReposWithJobs = async () => sew(() => +const getReposWithLatestJob = async () => sew(() => withOptionalAuthV2(async ({ prisma }) => { const repos = await prisma.repo.findMany({ include: { - jobs: true, + jobs: { + orderBy: { + createdAt: 'desc' + }, + take: 1 + } } }); - return repos; })); \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index b35de40b..c6038227 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -17,6 +17,7 @@ import { ErrorCode } from "./errorCodes"; import { NextRequest } from "next/server"; import { Org } from "@sourcebot/db"; import { OrgMetadata, orgMetadataSchema } from "@/types"; +import { SINGLE_TENANT_ORG_DOMAIN } from "./constants"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -440,7 +441,7 @@ export const measure = async (cb: () => Promise, measureName: string, outp export const unwrapServiceError = async (promise: Promise): Promise => { const data = await promise; if (isServiceError(data)) { - throw new Error(data.message); + throw new Error(data); } return data; @@ -458,7 +459,7 @@ export const requiredQueryParamGuard = (request: NextRequest, param: string): Se return value; } -export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, domain: string): string | undefined => { +export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): string | undefined => { if (!imageUrl) return undefined; try { @@ -478,7 +479,7 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, do return imageUrl; } else { // Use the proxied route for self-hosted instances - return `/api/${domain}/repos/${repoId}/image`; + return `/api/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repoId}/image`; } } catch { // If URL parsing fails, use the original URL