mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
repository table
This commit is contained in:
parent
cfb359351d
commit
c56433d8a9
10 changed files with 144 additions and 174 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) ?? ""}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,3 +58,7 @@ export const userScopedPrismaClientExtension = (userId?: string) => {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const getPrismaClient = (userId?: string) => {
|
||||
return prisma.$extends(userScopedPrismaClientExtension(userId)) as PrismaClient;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue