diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index b0efce8c..733e73a0 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -147,7 +147,7 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo } }) ); - + export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { @@ -321,7 +321,11 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt } : {}), }, include: { - connections: true, + connections: { + include: { + connection: true, + } + } } }); @@ -330,7 +334,10 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt repoId: repo.id, repoName: repo.name, repoCloneUrl: repo.cloneUrl, - linkedConnections: repo.connections.map((connection) => connection.connectionId), + linkedConnections: repo.connections.map(({ connection }) => ({ + id: connection.id, + name: connection.name, + })), imageUrl: repo.imageUrl ?? undefined, indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, @@ -883,7 +890,7 @@ export const createOnboardingSubscription = async (domain: string) => save_default_payment_method: 'on_subscription', }, }); - + if (!subscription) { return { statusCode: StatusCodes.INTERNAL_SERVER_ERROR, diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx index 63e9c006..859d87da 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx @@ -67,7 +67,7 @@ export default function SharedConnectionCreationForm({ return checkIfSecretExists(secretKey, domain); }, { message: "Secret not found" }), }); - }, [schema, domain]); + }, [schema, domain, additionalConfigValidation]); const form = useForm>({ resolver: zodResolver(formSchema), diff --git a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx index d557904d..e18c03a3 100644 --- a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx @@ -97,7 +97,7 @@ export const ErrorNavIndicator = () => { .slice(0, 10) .map(repo => ( // Link to the first connection for the repo - captureEvent('wa_error_nav_job_pressed', {})}> + captureEvent('wa_error_nav_job_pressed', {})}>
{inProgressRepos.slice(0, 10).map(item => ( // Link to the first connection for the repo - captureEvent('wa_progress_nav_job_pressed', {})}> + captureEvent('wa_progress_nav_job_pressed', {})}>
- {name} + {imageUrl ? ( + {name} + ) : ( +
+ {name.charAt(0)} +
+ )}

{name}

diff --git a/packages/web/src/app/[domain]/repos/addRepoButton.tsx b/packages/web/src/app/[domain]/repos/addRepoButton.tsx new file mode 100644 index 00000000..faedd7af --- /dev/null +++ b/packages/web/src/app/[domain]/repos/addRepoButton.tsx @@ -0,0 +1,57 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { PlusCircle } from "lucide-react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogClose, + DialogFooter, +} from "@/components/ui/dialog" +import { useState } from "react" +import { ConnectionList } from "../connections/components/connectionList" +import { useDomain } from "@/hooks/useDomain" +import Link from "next/link"; + +export function AddRepoButton() { + const [isOpen, setIsOpen] = useState(false) + const domain = useDomain() + + return ( + <> + + + + + + Add a New Repository + + Repositories are added to Sourcebot using connections. To add a new repo, add it to an existing connection or create a new one. + + +
+ +
+ + + + + + +
+
+ + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx index a239dce8..2bb93df7 100644 --- a/packages/web/src/app/[domain]/repos/columns.tsx +++ b/packages/web/src/app/[domain]/repos/columns.tsx @@ -1,141 +1,265 @@ -'use client'; +"use client" -import { Button } from "@/components/ui/button"; -import { Column, ColumnDef } from "@tanstack/react-table" -import { ArrowUpDown } from "lucide-react" -import prettyBytes from "pretty-bytes"; +import { Button } from "@/components/ui/button" +import type { ColumnDef } from "@tanstack/react-table" +import { ArrowUpDown, ExternalLink, Clock, Loader2, CheckCircle2, XCircle, Trash2, Check, ListFilter } from "lucide-react" +import Image from "next/image" +import { Badge } from "@/components/ui/badge" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { RepoIndexingStatus } from "@sourcebot/db"; +import { useDomain } from "@/hooks/useDomain" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { AddRepoButton } from "./addRepoButton" export type RepositoryColumnInfo = { - name: string; - branches: { - name: string, - version: string, - }[]; - repoSizeBytes: number; - indexedFiles: number; - indexSizeBytes: number; - shardCount: number; - lastIndexed: string; - latestCommit: string; - commitUrlTemplate: string; - url: string; + name: string + imageUrl?: string + connections: { + id: number + name: string + }[] + repoIndexingStatus: RepoIndexingStatus + lastIndexed: string + url: string } -export const columns: ColumnDef[] = [ +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 StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => { + let icon = null + let description = "" + let className = "" + + switch (status) { + case RepoIndexingStatus.NEW: + case RepoIndexingStatus.IN_INDEX_QUEUE: + icon = + 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 = + description = "Repository is being indexed" + className = "text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400" + break + case RepoIndexingStatus.INDEXED: + icon = + description = "Repository has been successfully indexed" + className = "text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400" + break + case RepoIndexingStatus.FAILED: + icon = + 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 = + 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 = + description = "Repository deletion failed" + className = "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400" + break + } + + return ( + + + +
+ {icon} + {statusLabels[status]} +
+
+ +

{description}

+
+
+
+ ) +} + +export const columns = (domain: string): ColumnDef[] => [ { accessorKey: "name", - header: "Name", + header: () => ( +
+ Repository + +
+ ), cell: ({ row }) => { - const repo = row.original; - const url = repo.url; - // local repositories will have a url of 0 length - const isRemoteRepo = url.length === 0; + const repo = row.original + const url = repo.url + const isRemoteRepo = url.length > 0 + return ( -
- { - if (!isRemoteRepo) { - window.open(url, "_blank"); - } - }} - > - {repo.name} - +
+
+ {repo.imageUrl ? ( + {`${repo.name} + ) : ( +
+ {repo.name.charAt(0)} +
+ )} +
+
+ { + if (isRemoteRepo) { + window.open(url, "_blank") + } + }} + > + {repo.name} + + {isRemoteRepo && } +
- ); - } + ) + }, }, { - accessorKey: "branches", - header: "Branches", + accessorKey: "connections", + header: () =>
Connections
, cell: ({ row }) => { - const branches = row.original.branches; - - if (branches.length === 0) { - return
N/A
; + const connections = row.original.connections + + if (!connections || connections.length === 0) { + return
} return ( -
- {branches.map(({ name, version }, index) => { - const shortVersion = version.substring(0, 8); - return ( - - {name} - @ - { - const url = row.original.commitUrlTemplate.replace("{{.Version}}", version); - window.open(url, "_blank"); - }} - > - {shortVersion} - - - ) - })} +
+ {connections.map((connection) => ( + { + window.location.href = `/${domain}/connections/${connection.id}` + }} + > + {connection.name} + + + ))}
- ); + ) }, }, { - accessorKey: "shardCount", - header: ({ column }) => createSortHeader("Shard Count", column), - cell: ({ row }) => ( -
{row.original.shardCount}
- ) - }, - { - accessorKey: "indexedFiles", - header: ({ column }) => createSortHeader("Indexed Files", column), - cell: ({ row }) => ( -
{row.original.indexedFiles}
- ) - }, - { - accessorKey: "indexSizeBytes", - header: ({ column }) => createSortHeader("Index Size", column), - cell: ({ row }) => { - const size = prettyBytes(row.original.indexSizeBytes); - return
{size}
; - } - }, - { - accessorKey: "repoSizeBytes", - header: ({ column }) => createSortHeader("Repository Size", column), + accessorKey: "repoIndexingStatus", + header: ({ column }) => { + const uniqueLabels = Array.from(new Set(Object.values(statusLabels))); + const currentFilter = column.getFilterValue() as string | undefined; + + return ( +
+ + + + + + column.setFilterValue(undefined)}> + + All + + {uniqueLabels.map((label) => ( + column.setFilterValue(label)}> + + {label} + + ))} + + +
+ ) + }, cell: ({ row }) => { - const size = prettyBytes(row.original.repoSizeBytes); - return
{size}
; - } + return + }, + filterFn: (row, id, value) => { + if (value === undefined) return true; + + const status = row.getValue(id) as RepoIndexingStatus; + return statusLabels[status] === value; + }, }, { accessorKey: "lastIndexed", - header: ({ column }) => createSortHeader("Last Indexed", column), + header: ({ column }) => ( +
+ +
+ ), cell: ({ row }) => { - const date = new Date(row.original.lastIndexed); - return date.toISOString(); - } + if (!row.original.lastIndexed) { + return
-
; + } + const date = new Date(row.original.lastIndexed) + return ( +
+
+ {date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+
+ {date + .toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }) + .toLowerCase()} +
+
+ ) + }, }, - { - accessorKey: "latestCommit", - header: ({ column }) => createSortHeader("Latest Commit", column), - cell: ({ row }) => { - const date = new Date(row.original.latestCommit); - return date.toISOString(); - } - } ] - -const createSortHeader = (name: string, column: Column) => { - return ( - - ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/layout.tsx b/packages/web/src/app/[domain]/repos/layout.tsx new file mode 100644 index 00000000..19c0c99c --- /dev/null +++ b/packages/web/src/app/[domain]/repos/layout.tsx @@ -0,0 +1,19 @@ +import { NavigationMenu } from "../components/navigationMenu"; + +export default function Layout({ + children, + params: { domain }, +}: Readonly<{ + children: React.ReactNode; + params: { domain: string }; +}>) { + + return ( +
+ +
+
{children}
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index 917c521a..843d4bf1 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -1,23 +1,24 @@ -import { Suspense } from "react"; import { NavigationMenu } from "../components/navigationMenu"; import { RepositoryTable } from "./repositoryTable"; import { getOrgFromDomain } from "@/data/org"; import { PageNotFound } from "../components/pageNotFound"; - +import { Header } from "../components/header"; export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) { const org = await getOrgFromDomain(domain); if (!org) { return } - + return ( -
- - Loading...
}> -
- +
+
+

Repositories

+
+
+
+
- +
) -} \ No newline at end of file +} diff --git a/packages/web/src/app/[domain]/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx index 47b96080..6abcf923 100644 --- a/packages/web/src/app/[domain]/repos/repositoryTable.tsx +++ b/packages/web/src/app/[domain]/repos/repositoryTable.tsx @@ -1,41 +1,88 @@ +"use client"; + import { DataTable } from "@/components/ui/data-table"; import { columns, RepositoryColumnInfo } from "./columns"; -import { listRepositories } from "@/lib/server/searchService"; -import { isServiceError } from "@/lib/utils"; +import { unwrapServiceError } from "@/lib/utils"; +import { getRepos } from "@/actions"; +import { useQuery } from "@tanstack/react-query"; +import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { useDomain } from "@/hooks/useDomain"; +import { RepoIndexingStatus } from "@sourcebot/db"; +import { useMemo } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; -export const RepositoryTable = async ({ orgId }: { orgId: number }) => { - const _repos = await listRepositories(orgId); +export const RepositoryTable = () => { + const domain = useDomain(); + const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({ + queryKey: ['repos', domain], + queryFn: async () => { + return await unwrapServiceError(getRepos(domain)); + }, + refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchIntervalInBackground: true, + }); - if (isServiceError(_repos)) { - return
Error fetching repositories
; - } + const tableRepos = useMemo(() => { + if (reposLoading) return Array(4).fill(null).map(() => ({ + name: "", + connections: [], + repoIndexingStatus: RepoIndexingStatus.NEW, + lastIndexed: "", + url: "", + imageUrl: "", + })); - const repos = _repos.List.Repos.map((repo): RepositoryColumnInfo => { - return { - name: repo.Repository.Name, - branches: (repo.Repository.Branches ?? []).map((branch) => { + if (!repos) return []; + return repos.map((repo): RepositoryColumnInfo => ({ + name: repo.repoName.split('/').length > 2 ? repo.repoName.split('/').slice(-2).join('/') : repo.repoName, + imageUrl: repo.imageUrl, + connections: repo.linkedConnections, + repoIndexingStatus: repo.repoIndexingStatus as RepoIndexingStatus, + lastIndexed: repo.indexedAt?.toISOString() ?? "", + url: repo.repoCloneUrl, + })).sort((a, b) => { + return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime(); + }); + }, [repos, reposLoading]); + + const tableColumns = useMemo(() => { + if (reposLoading) { + return columns(domain).map((column) => { + if ('accessorKey' in column && column.accessorKey === "name") { + return { + ...column, + cell: () => ( +
+ {/* Avatar skeleton */} + {/* Repository name skeleton */} +
+ ), + } + } + return { - name: branch.Name, - version: branch.Version, + ...column, + cell: () => ( +
+ +
+ ), } - }), - repoSizeBytes: repo.Stats.ContentBytes, - indexSizeBytes: repo.Stats.IndexBytes, - shardCount: repo.Stats.Shards, - lastIndexed: repo.IndexMetadata.IndexTime, - latestCommit: repo.Repository.LatestCommitDate, - indexedFiles: repo.Stats.Documents, - commitUrlTemplate: repo.Repository.CommitURLTemplate, - url: repo.Repository.URL, + }) } - }).sort((a, b) => { - return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime(); - }); + + return columns(domain); + }, [reposLoading, domain]); + + + if (reposError) { + return
Error loading repositories
; + } return ( diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 1a72adf5..07b8e4cd 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -167,7 +167,10 @@ export const repositoryQuerySchema = z.object({ repoId: z.number(), repoName: z.string(), repoCloneUrl: z.string(), - linkedConnections: z.array(z.number()), + linkedConnections: z.array(z.object({ + id: z.number(), + name: z.string(), + })), imageUrl: z.string().optional(), indexedAt: z.date().optional(), repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),