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?.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 (
-
-
-
-
({
- 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