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 { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, getShortenedNumberDisplayString } from "@/lib/utils";
import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, CircleIcon } from "lucide-react";
import { usePathname } from "next/navigation";

View file

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

View file

@ -2,72 +2,51 @@
import { Button } from "@/components/ui/button"
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn, getRepoImageSrc } from "@/lib/utils"
import { RepoIndexingStatus } from "@sourcebot/db";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import Link from "next/link"
import { getBrowsePath } from "../browse/hooks/utils"
export type RepoStatus = 'syncing' | 'indexed' | 'not-indexed';
export type RepositoryColumnInfo = {
repoId: number
repoName: string;
repoDisplayName: string
imageUrl?: string
repoIndexingStatus: RepoIndexingStatus
status: RepoStatus
lastIndexed: string
}
const statusLabels = {
[RepoIndexingStatus.NEW]: "Queued",
[RepoIndexingStatus.IN_INDEX_QUEUE]: "Queued",
[RepoIndexingStatus.INDEXING]: "Indexing",
[RepoIndexingStatus.INDEXED]: "Indexed",
[RepoIndexingStatus.FAILED]: "Failed",
[RepoIndexingStatus.IN_GC_QUEUE]: "Deleting",
[RepoIndexingStatus.GARBAGE_COLLECTING]: "Deleting",
[RepoIndexingStatus.GARBAGE_COLLECTION_FAILED]: "Deletion Failed"
const statusLabels: Record<RepoStatus, string> = {
'syncing': "Syncing",
'indexed': "Indexed",
'not-indexed': "Pending",
};
const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
const StatusIndicator = ({ status }: { status: RepoStatus }) => {
let icon = null
let description = ""
let className = ""
switch (status) {
case RepoIndexingStatus.NEW:
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:
case 'syncing':
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"
break
case RepoIndexingStatus.INDEXED:
case 'indexed':
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"
break
case RepoIndexingStatus.FAILED:
icon = <XCircle className="h-3.5 w-3.5" />
description = "Repository indexing failed"
className = "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-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"
case 'not-indexed':
icon = <Clock className="h-3.5 w-3.5" />
description = "Repository is pending initial sync"
className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400"
break
}
@ -130,9 +109,9 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
},
},
{
accessorKey: "repoIndexingStatus",
accessorKey: "status",
header: ({ column }) => {
const uniqueLabels = Array.from(new Set(Object.values(statusLabels)));
const uniqueLabels = Object.values(statusLabels);
const currentFilter = column.getFilterValue() as string | undefined;
return (
@ -173,12 +152,12 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
)
},
cell: ({ row }) => {
return <StatusIndicator status={row.original.repoIndexingStatus} />
return <StatusIndicator status={row.original.status} />
},
filterFn: (row, id, value) => {
if (value === undefined) return true;
const status = row.getValue(id) as RepoIndexingStatus;
const status = row.getValue(id) as RepoStatus;
return statusLabels[status] === value;
},
},
@ -191,14 +170,14 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
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"
>
Last Indexed
Last Synced
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
</Button>
</div>
),
cell: ({ row }) => {
if (!row.original.lastIndexed) {
return <div>-</div>;
return <div className="text-muted-foreground">Never</div>;
}
const date = new Date(row.original.lastIndexed)
return (

View file

@ -1,8 +1,20 @@
import { RepositoryTable } from "./repositoryTable";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "../components/pageNotFound";
import { Header } from "../components/header";
import { auth } from "@/auth";
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 }> }) {
const params = await props.params;
@ -11,23 +23,34 @@ export default async function ReposPage(props: { params: Promise<{ domain: strin
domain
} = params;
const org = await getOrgFromDomain(domain);
if (!org) {
return <PageNotFound />
const session = await auth();
const prisma = getPrismaClient(session?.user?.id);
const repos = await prisma.repo.findMany({
include: {
jobs: true,
}
});
return (
<div>
<Header>
<h1 className="text-3xl">Repositories</h1>
</Header>
<div className="flex flex-col items-center">
<div className="w-full">
<div className="px-6 py-6">
<RepositoryTable
repos={repos.map((repo) => ({
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'}
/>
</div>
</div>
</div>
)
}

View file

@ -1,118 +1,81 @@
"use client";
import { DataTable } from "@/components/ui/data-table";
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 { useToast } from "@/components/hooks/use-toast";
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 { useState } from "react";
import { getRepos } from "@/app/api/(client)/client";
interface RepositoryTableProps {
isAddReposButtonVisible: boolean
repos: {
repoId: number;
repoName: string;
repoDisplayName: string;
imageUrl?: string;
indexedAt?: Date;
status: RepoStatus;
}[];
domain: string;
isAddReposButtonVisible: boolean;
}
export const RepositoryTable = ({
repos,
domain,
isAddReposButtonVisible,
}: RepositoryTableProps) => {
const domain = useDomain();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
queryKey: ['repos'],
queryFn: async () => {
return await unwrapServiceError(getRepos());
},
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
refetchIntervalInBackground: true,
});
const router = useRouter();
const { toast } = useToast();
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 => ({
repoId: repo.repoId,
repoName: repo.repoName,
repoDisplayName: repo.repoDisplayName ?? repo.repoName,
imageUrl: repo.imageUrl,
repoIndexingStatus: repo.repoIndexingStatus as RepoIndexingStatus,
status: repo.status,
lastIndexed: repo.indexedAt?.toISOString() ?? "",
})).sort((a, b) => {
const getPriorityFromStatus = (status: RepoIndexingStatus) => {
const getPriorityFromStatus = (status: RepoStatus) => {
switch (status) {
case RepoIndexingStatus.IN_INDEX_QUEUE:
case RepoIndexingStatus.INDEXING:
return 0 // Highest priority - currently indexing
case RepoIndexingStatus.FAILED:
return 1 // Second priority - failed repos need attention
case RepoIndexingStatus.INDEXED:
case 'syncing':
return 0 // Highest priority - currently syncing
case 'not-indexed':
return 1 // Second priority - not yet indexed
case 'indexed':
return 2 // Third priority - successfully indexed
default:
return 3 // Lowest priority - other statuses (NEW, etc.)
return 3
}
}
// Sort by priority first
const aPriority = getPriorityFromStatus(a.repoIndexingStatus);
const bPriority = getPriorityFromStatus(b.repoIndexingStatus);
const aPriority = getPriorityFromStatus(a.status);
const bPriority = getPriorityFromStatus(b.status);
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 (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(() => {
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);
}, [reposLoading, domain]);
if (reposError) {
return <div>Error loading repositories</div>;
}
}, [domain]);
return (
<>
@ -121,7 +84,22 @@ export const RepositoryTable = ({
data={tableRepos}
searchKey="repoDisplayName"
searchPlaceholder="Search repositories..."
headerActions={isAddReposButtonVisible && (
headerActions={(
<div className="flex items-center justify-between w-full gap-2">
<Button
variant="outline"
size="default"
className="ml-2"
onClick={() => {
router.refresh();
toast({
description: "Page refreshed",
});
}}>
<RefreshCwIcon className="w-4 h-4" />
Refresh
</Button>
{isAddReposButtonVisible && (
<Button
variant="default"
size="default"
@ -131,6 +109,8 @@ export const RepositoryTable = ({
Add repository
</Button>
)}
</div>
)}
/>
<AddRepositoryDialog

View file

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

View file

@ -62,7 +62,7 @@ export function DataTable<TData, TValue>({
return (
<div>
<div className="flex items-center justify-between py-4">
<div className="flex items-center py-4">
<Input
placeholder={searchPlaceholder}
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}`);
// 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

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