repository table

This commit is contained in:
bkellam 2025-10-16 23:12:41 -07:00
parent cfb359351d
commit c56433d8a9
10 changed files with 144 additions and 174 deletions

View file

@ -2,7 +2,6 @@
import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, getShortenedNumberDisplayString } from "@/lib/utils"; import { cn, getShortenedNumberDisplayString } from "@/lib/utils";
import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, CircleIcon } from "lucide-react"; import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, CircleIcon } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";

View file

@ -1,5 +1,6 @@
'use client'; 'use client';
import { useToast } from "@/components/hooks/use-toast";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@ -25,6 +26,7 @@ export const ProgressIndicator = ({
}: ProgressIndicatorProps) => { }: ProgressIndicatorProps) => {
const domain = useDomain(); const domain = useDomain();
const router = useRouter(); const router = useRouter();
const { toast } = useToast();
if (numRepos === 0) { if (numRepos === 0) {
return null; return null;
@ -51,6 +53,9 @@ export const ProgressIndicator = ({
className="h-6 w-6 text-muted-foreground" className="h-6 w-6 text-muted-foreground"
onClick={() => { onClick={() => {
router.refresh(); router.refresh();
toast({
description: "Page refreshed",
});
}} }}
> >
<RefreshCwIcon className="w-3 h-3" /> <RefreshCwIcon className="w-3 h-3" />
@ -105,14 +110,13 @@ const RepoItem = ({ repo }: { repo: RepositoryQuery }) => {
return ( return (
<Link <div
href={'/'}
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")} className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
> >
{repoIcon} {repoIcon}
<span className="text-sm truncate"> <span className="text-sm truncate">
{displayName} {displayName}
</span> </span>
</Link> </div>
) )
} }

View file

@ -2,72 +2,51 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { ArrowUpDown, Clock, Loader2, CheckCircle2, XCircle, Trash2, Check, ListFilter } from "lucide-react" import { ArrowUpDown, Clock, Loader2, CheckCircle2, Check, ListFilter } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn, getRepoImageSrc } from "@/lib/utils" import { cn, getRepoImageSrc } from "@/lib/utils"
import { RepoIndexingStatus } from "@sourcebot/db";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import Link from "next/link" import Link from "next/link"
import { getBrowsePath } from "../browse/hooks/utils" import { getBrowsePath } from "../browse/hooks/utils"
export type RepoStatus = 'syncing' | 'indexed' | 'not-indexed';
export type RepositoryColumnInfo = { export type RepositoryColumnInfo = {
repoId: number repoId: number
repoName: string; repoName: string;
repoDisplayName: string repoDisplayName: string
imageUrl?: string imageUrl?: string
repoIndexingStatus: RepoIndexingStatus status: RepoStatus
lastIndexed: string lastIndexed: string
} }
const statusLabels = { const statusLabels: Record<RepoStatus, string> = {
[RepoIndexingStatus.NEW]: "Queued", 'syncing': "Syncing",
[RepoIndexingStatus.IN_INDEX_QUEUE]: "Queued", 'indexed': "Indexed",
[RepoIndexingStatus.INDEXING]: "Indexing", 'not-indexed': "Pending",
[RepoIndexingStatus.INDEXED]: "Indexed",
[RepoIndexingStatus.FAILED]: "Failed",
[RepoIndexingStatus.IN_GC_QUEUE]: "Deleting",
[RepoIndexingStatus.GARBAGE_COLLECTING]: "Deleting",
[RepoIndexingStatus.GARBAGE_COLLECTION_FAILED]: "Deletion Failed"
}; };
const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => { const StatusIndicator = ({ status }: { status: RepoStatus }) => {
let icon = null let icon = null
let description = "" let description = ""
let className = "" let className = ""
switch (status) { switch (status) {
case RepoIndexingStatus.NEW: case 'syncing':
case RepoIndexingStatus.IN_INDEX_QUEUE:
icon = <Clock className="h-3.5 w-3.5" />
description = "Repository is queued for indexing"
className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400"
break
case RepoIndexingStatus.INDEXING:
icon = <Loader2 className="h-3.5 w-3.5 animate-spin" /> icon = <Loader2 className="h-3.5 w-3.5 animate-spin" />
description = "Repository is being indexed" description = "Repository is currently syncing"
className = "text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400" className = "text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400"
break break
case RepoIndexingStatus.INDEXED: case 'indexed':
icon = <CheckCircle2 className="h-3.5 w-3.5" /> icon = <CheckCircle2 className="h-3.5 w-3.5" />
description = "Repository has been successfully indexed" description = "Repository has been successfully indexed and is up to date"
className = "text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400" className = "text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400"
break break
case RepoIndexingStatus.FAILED: case 'not-indexed':
icon = <XCircle className="h-3.5 w-3.5" /> icon = <Clock className="h-3.5 w-3.5" />
description = "Repository indexing failed" description = "Repository is pending initial sync"
className = "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400" className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400"
break
case RepoIndexingStatus.IN_GC_QUEUE:
case RepoIndexingStatus.GARBAGE_COLLECTING:
icon = <Trash2 className="h-3.5 w-3.5" />
description = "Repository is being deleted"
className = "text-gray-600 bg-gray-50 dark:bg-gray-900/20 dark:text-gray-400"
break
case RepoIndexingStatus.GARBAGE_COLLECTION_FAILED:
icon = <XCircle className="h-3.5 w-3.5" />
description = "Repository deletion failed"
className = "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400"
break break
} }
@ -130,9 +109,9 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
}, },
}, },
{ {
accessorKey: "repoIndexingStatus", accessorKey: "status",
header: ({ column }) => { header: ({ column }) => {
const uniqueLabels = Array.from(new Set(Object.values(statusLabels))); const uniqueLabels = Object.values(statusLabels);
const currentFilter = column.getFilterValue() as string | undefined; const currentFilter = column.getFilterValue() as string | undefined;
return ( return (
@ -173,12 +152,12 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
) )
}, },
cell: ({ row }) => { cell: ({ row }) => {
return <StatusIndicator status={row.original.repoIndexingStatus} /> return <StatusIndicator status={row.original.status} />
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
if (value === undefined) return true; if (value === undefined) return true;
const status = row.getValue(id) as RepoIndexingStatus; const status = row.getValue(id) as RepoStatus;
return statusLabels[status] === value; return statusLabels[status] === value;
}, },
}, },
@ -191,14 +170,14 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="px-0 font-medium hover:bg-transparent focus:bg-transparent active:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" className="px-0 font-medium hover:bg-transparent focus:bg-transparent active:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
> >
Last Indexed Last Synced
<ArrowUpDown className="ml-2 h-3.5 w-3.5" /> <ArrowUpDown className="ml-2 h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
), ),
cell: ({ row }) => { cell: ({ row }) => {
if (!row.original.lastIndexed) { if (!row.original.lastIndexed) {
return <div>-</div>; return <div className="text-muted-foreground">Never</div>;
} }
const date = new Date(row.original.lastIndexed) const date = new Date(row.original.lastIndexed)
return ( return (

View file

@ -1,8 +1,20 @@
import { RepositoryTable } from "./repositoryTable"; import { auth } from "@/auth";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "../components/pageNotFound";
import { Header } from "../components/header";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { getPrismaClient } from "@/prisma";
import { RepoJob } from "@sourcebot/db";
import { Header } from "../components/header";
import { RepoStatus } from "./columns";
import { RepositoryTable } from "./repositoryTable";
function getRepoStatus(repo: { indexedAt: Date | null, jobs: RepoJob[] }): 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(props: { params: Promise<{ domain: string }> }) { export default async function ReposPage(props: { params: Promise<{ domain: string }> }) {
const params = await props.params; const params = await props.params;
@ -11,22 +23,33 @@ export default async function ReposPage(props: { params: Promise<{ domain: strin
domain domain
} = params; } = params;
const org = await getOrgFromDomain(domain); const session = await auth();
if (!org) { const prisma = getPrismaClient(session?.user?.id);
return <PageNotFound />
} const repos = await prisma.repo.findMany({
include: {
jobs: true,
}
});
return ( return (
<div> <div>
<Header> <Header>
<h1 className="text-3xl">Repositories</h1> <h1 className="text-3xl">Repositories</h1>
</Header> </Header>
<div className="flex flex-col items-center"> <div className="px-6 py-6">
<div className="w-full"> <RepositoryTable
<RepositoryTable repos={repos.map((repo) => ({
isAddReposButtonVisible={env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED === 'true'} repoId: repo.id,
/> repoName: repo.name,
</div> 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'}
/>
</div> </div>
</div> </div>
) )

View file

@ -1,118 +1,81 @@
"use client"; "use client";
import { DataTable } from "@/components/ui/data-table"; import { useToast } from "@/components/hooks/use-toast";
import { columns, RepositoryColumnInfo } from "./columns";
import { unwrapServiceError } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { useDomain } from "@/hooks/useDomain";
import { RepoIndexingStatus } from "@sourcebot/db";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { env } from "@/env.mjs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react"; import { DataTable } from "@/components/ui/data-table";
import { PlusIcon, RefreshCwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { columns, RepositoryColumnInfo, RepoStatus } from "./columns";
import { AddRepositoryDialog } from "./components/addRepositoryDialog"; import { AddRepositoryDialog } from "./components/addRepositoryDialog";
import { useState } from "react";
import { getRepos } from "@/app/api/(client)/client";
interface RepositoryTableProps { interface RepositoryTableProps {
isAddReposButtonVisible: boolean repos: {
repoId: number;
repoName: string;
repoDisplayName: string;
imageUrl?: string;
indexedAt?: Date;
status: RepoStatus;
}[];
domain: string;
isAddReposButtonVisible: boolean;
} }
export const RepositoryTable = ({ export const RepositoryTable = ({
repos,
domain,
isAddReposButtonVisible, isAddReposButtonVisible,
}: RepositoryTableProps) => { }: RepositoryTableProps) => {
const domain = useDomain();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const router = useRouter();
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({ const { toast } = useToast();
queryKey: ['repos'],
queryFn: async () => {
return await unwrapServiceError(getRepos());
},
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
refetchIntervalInBackground: true,
});
const tableRepos = useMemo(() => { const tableRepos = useMemo(() => {
if (reposLoading) return Array(4).fill(null).map(() => ({
repoId: 0,
repoName: "",
repoDisplayName: "",
repoIndexingStatus: RepoIndexingStatus.NEW,
lastIndexed: "",
imageUrl: "",
}));
if (!repos) return [];
return repos.map((repo): RepositoryColumnInfo => ({ return repos.map((repo): RepositoryColumnInfo => ({
repoId: repo.repoId, repoId: repo.repoId,
repoName: repo.repoName, repoName: repo.repoName,
repoDisplayName: repo.repoDisplayName ?? repo.repoName, repoDisplayName: repo.repoDisplayName ?? repo.repoName,
imageUrl: repo.imageUrl, imageUrl: repo.imageUrl,
repoIndexingStatus: repo.repoIndexingStatus as RepoIndexingStatus, status: repo.status,
lastIndexed: repo.indexedAt?.toISOString() ?? "", lastIndexed: repo.indexedAt?.toISOString() ?? "",
})).sort((a, b) => { })).sort((a, b) => {
const getPriorityFromStatus = (status: RepoIndexingStatus) => { const getPriorityFromStatus = (status: RepoStatus) => {
switch (status) { switch (status) {
case RepoIndexingStatus.IN_INDEX_QUEUE: case 'syncing':
case RepoIndexingStatus.INDEXING: return 0 // Highest priority - currently syncing
return 0 // Highest priority - currently indexing case 'not-indexed':
case RepoIndexingStatus.FAILED: return 1 // Second priority - not yet indexed
return 1 // Second priority - failed repos need attention case 'indexed':
case RepoIndexingStatus.INDEXED:
return 2 // Third priority - successfully indexed return 2 // Third priority - successfully indexed
default: default:
return 3 // Lowest priority - other statuses (NEW, etc.) return 3
} }
} }
// Sort by priority first // Sort by priority first
const aPriority = getPriorityFromStatus(a.repoIndexingStatus); const aPriority = getPriorityFromStatus(a.status);
const bPriority = getPriorityFromStatus(b.repoIndexingStatus); const bPriority = getPriorityFromStatus(b.status);
if (aPriority !== bPriority) { if (aPriority !== bPriority) {
return aPriority - bPriority; // Lower priority number = higher precedence return aPriority - bPriority;
} }
// If same priority, sort by last indexed date (most recent first) // If same priority, sort by last indexed date (most recent first)
return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime(); if (a.lastIndexed && b.lastIndexed) {
return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime();
}
// Put items without dates at the end
if (!a.lastIndexed) return 1;
if (!b.lastIndexed) return -1;
return 0;
}); });
}, [repos, reposLoading]); }, [repos]);
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
if (reposLoading) {
return columns(domain).map((column) => {
if ('accessorKey' in column && column.accessorKey === "name") {
return {
...column,
cell: () => (
<div className="flex flex-row items-center gap-3 py-2">
<Skeleton className="h-8 w-8 rounded-md" /> {/* Avatar skeleton */}
<Skeleton className="h-4 w-48" /> {/* Repository name skeleton */}
</div>
),
}
}
return {
...column,
cell: () => (
<div className="flex flex-wrap gap-1.5">
<Skeleton className="h-5 w-24 rounded-full" />
</div>
),
}
})
}
return columns(domain); return columns(domain);
}, [reposLoading, domain]); }, [domain]);
if (reposError) {
return <div>Error loading repositories</div>;
}
return ( return (
<> <>
@ -121,18 +84,35 @@ export const RepositoryTable = ({
data={tableRepos} data={tableRepos}
searchKey="repoDisplayName" searchKey="repoDisplayName"
searchPlaceholder="Search repositories..." searchPlaceholder="Search repositories..."
headerActions={isAddReposButtonVisible && ( headerActions={(
<Button <div className="flex items-center justify-between w-full gap-2">
variant="default" <Button
size="default" variant="outline"
onClick={() => setIsAddDialogOpen(true)} size="default"
> className="ml-2"
<PlusIcon className="w-4 h-4" /> onClick={() => {
Add repository router.refresh();
</Button> toast({
description: "Page refreshed",
});
}}>
<RefreshCwIcon className="w-4 h-4" />
Refresh
</Button>
{isAddReposButtonVisible && (
<Button
variant="default"
size="default"
onClick={() => setIsAddDialogOpen(true)}
>
<PlusIcon className="w-4 h-4" />
Add repository
</Button>
)}
</div>
)} )}
/> />
<AddRepositoryDialog <AddRepositoryDialog
isOpen={isAddDialogOpen} isOpen={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen} onOpenChange={setIsAddDialogOpen}

View file

@ -94,10 +94,6 @@ export default async function SettingsLayout(
), ),
href: `/${domain}/settings/members`, href: `/${domain}/settings/members`,
}] : []), }] : []),
...(userRoleInOrg === OrgRole.OWNER ? [{
title: "Connections",
href: `/${domain}/connections`,
}] : []),
{ {
title: "Secrets", title: "Secrets",
href: `/${domain}/settings/secrets`, href: `/${domain}/settings/secrets`,

View file

@ -62,7 +62,7 @@ export function DataTable<TData, TValue>({
return ( return (
<div> <div>
<div className="flex items-center justify-between py-4"> <div className="flex items-center py-4">
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""} value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}

View file

@ -64,21 +64,6 @@ const syncConnections = async (connections?: { [key: string]: ConnectionConfig }
}); });
logger.info(`Upserted connection with name '${key}'. Connection ID: ${connectionDb.id}`); logger.info(`Upserted connection with name '${key}'. Connection ID: ${connectionDb.id}`);
// Re-try any repos that failed to index.
const failedRepos = currentConnection?.repos.filter(repo => repo.repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map(repo => repo.repo.id) ?? [];
if (failedRepos.length > 0) {
await prisma.repo.updateMany({
where: {
id: {
in: failedRepos,
}
},
data: {
repoIndexingStatus: RepoIndexingStatus.NEW,
}
})
}
} }
} }

View file

@ -57,4 +57,8 @@ export const userScopedPrismaClientExtension = (userId?: string) => {
} }
}) })
}) })
}
export const getPrismaClient = (userId?: string) => {
return prisma.$extends(userScopedPrismaClientExtension(userId)) as PrismaClient;
} }

View file

@ -1,4 +1,4 @@
import { prisma as __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma"; import { getPrismaClient, prisma as __unsafePrisma } from "@/prisma";
import { hashSecret } from "@sourcebot/crypto"; import { hashSecret } from "@sourcebot/crypto";
import { ApiKey, Org, OrgRole, PrismaClient, User } from "@sourcebot/db"; import { ApiKey, Org, OrgRole, PrismaClient, User } from "@sourcebot/db";
import { headers } from "next/headers"; import { headers } from "next/headers";
@ -88,7 +88,7 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
}, },
}) : null; }) : null;
const prisma = __unsafePrisma.$extends(userScopedPrismaClientExtension(user?.id)) as PrismaClient; const prisma = getPrismaClient(user?.id);
return { return {
user: user ?? undefined, user: user ?? undefined,