diff --git a/packages/web/src/app/[domain]/chat/components/demoCards.tsx b/packages/web/src/app/[domain]/chat/components/demoCards.tsx index 016d9605..5c8d4e5c 100644 --- a/packages/web/src/app/[domain]/chat/components/demoCards.tsx +++ b/packages/web/src/app/[domain]/chat/components/demoCards.tsx @@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card"; import { DemoExamples, DemoSearchExample, DemoSearchScope } from "@/types"; -import { cn, getCodeHostIcon } from "@/lib/utils"; +import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard"; @@ -41,25 +41,23 @@ export const DemoCards = ({ } if (searchScope.codeHostType) { - const codeHostIcon = getCodeHostIcon(searchScope.codeHostType); - if (codeHostIcon) { - // When selected, icons need to match the inverted badge colors - // In light mode selected: light icon on dark bg (invert) - // In dark mode selected: dark icon on light bg (no invert, override dark:invert) - const selectedIconClass = isSelected - ? "invert dark:invert-0" - : codeHostIcon.className; + const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType); + // When selected, icons need to match the inverted badge colors + // In light mode selected: light icon on dark bg (invert) + // In dark mode selected: dark icon on light bg (no invert, override dark:invert) + const selectedIconClass = isSelected + ? "invert dark:invert-0" + : codeHostIcon.className; - return ( - {`${searchScope.codeHostType} - ); - } + return ( + {`${searchScope.codeHostType} + ); } return ; diff --git a/packages/web/src/app/[domain]/components/backButton.tsx b/packages/web/src/app/[domain]/components/backButton.tsx new file mode 100644 index 00000000..191715c2 --- /dev/null +++ b/packages/web/src/app/[domain]/components/backButton.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils"; +import { ArrowLeft } from "lucide-react" +import Link from "next/link" + +interface BackButtonProps { + href: string; + name: string; + className?: string; +} + +export function BackButton({ href, name, className }: BackButtonProps) { + return ( + + + + {name} + + + ) +} diff --git a/packages/web/src/app/[domain]/repos/[id]/page.tsx b/packages/web/src/app/[domain]/repos/[id]/page.tsx index 715afa5c..93289206 100644 --- a/packages/web/src/app/[domain]/repos/[id]/page.tsx +++ b/packages/web/src/app/[domain]/repos/[id]/page.tsx @@ -4,21 +4,21 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { env } from "@/env.mjs" import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { ServiceErrorException } from "@/lib/serviceError" import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils" import { withOptionalAuthV2 } from "@/withAuthV2" -import { ChevronLeft, ExternalLink, Info } from "lucide-react" +import { getConfigSettings, repoMetadataSchema } from "@sourcebot/shared" +import { ExternalLink, Info } from "lucide-react" import Image from "next/image" import Link from "next/link" import { notFound } from "next/navigation" import { Suspense } from "react" -import { RepoJobsTable } from "../components/repoJobsTable" -import { getConfigSettings } from "@sourcebot/shared" -import { env } from "@/env.mjs" +import { BackButton } from "../../components/backButton" import { DisplayDate } from "../../components/DisplayDate" import { RepoBranchesTable } from "../components/repoBranchesTable" -import { repoMetadataSchema } from "@sourcebot/shared" +import { RepoJobsTable } from "../components/repoJobsTable" export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params @@ -52,14 +52,13 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: const repoMetadata = repoMetadataSchema.parse(repo.metadata); return ( -
+ <>
- +
@@ -168,7 +167,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: - Indexing Jobs + Indexing History History of all indexing and cleanup jobs for this repository. @@ -177,16 +176,17 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: -
+ ) } const getRepoWithJobs = async (repoId: number) => sew(() => - withOptionalAuthV2(async ({ prisma }) => { + withOptionalAuthV2(async ({ prisma, org }) => { const repo = await prisma.repo.findUnique({ where: { id: repoId, + orgId: org.id, }, include: { jobs: { diff --git a/packages/web/src/app/[domain]/repos/components/reposTable.tsx b/packages/web/src/app/[domain]/repos/components/reposTable.tsx index 755d638a..2030c4c9 100644 --- a/packages/web/src/app/[domain]/repos/components/reposTable.tsx +++ b/packages/web/src/app/[domain]/repos/components/reposTable.tsx @@ -331,7 +331,7 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
- +
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/packages/web/src/app/[domain]/repos/layout.tsx b/packages/web/src/app/[domain]/repos/layout.tsx index 6ff10adf..f119d94f 100644 --- a/packages/web/src/app/[domain]/repos/layout.tsx +++ b/packages/web/src/app/[domain]/repos/layout.tsx @@ -16,7 +16,11 @@ export default async function Layout(
-
{children}
+
+
+ {children} +
+
) diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index 3e612081..31830e46 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -12,7 +12,7 @@ export default async function ReposPage() { } return ( -
+ <>

Repositories

View and manage your code repositories and their indexing status.

@@ -31,12 +31,12 @@ export default async function ReposPage() { codeHostType: repo.external_codeHostType, indexedCommitHash: repo.indexedCommitHash, }))} /> -
+ ) } const getReposWithLatestJob = async () => sew(() => - withOptionalAuthV2(async ({ prisma }) => { + withOptionalAuthV2(async ({ prisma, org }) => { const repos = await prisma.repo.findMany({ include: { jobs: { @@ -48,6 +48,9 @@ const getReposWithLatestJob = async () => sew(() => }, orderBy: { name: 'asc' + }, + where: { + orgId: org.id, } }); return repos; diff --git a/packages/web/src/app/[domain]/settings/components/header.tsx b/packages/web/src/app/[domain]/settings/components/header.tsx deleted file mode 100644 index 79a24ee4..00000000 --- a/packages/web/src/app/[domain]/settings/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]/settings/components/sidebar-nav.tsx b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx index ddf37adb..393773ec 100644 --- a/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx +++ b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx @@ -6,39 +6,46 @@ import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" -interface SidebarNavProps extends React.HTMLAttributes { - items: { +export type SidebarNavItem = { href: string + hrefRegex?: string title: React.ReactNode - }[] +} + +interface SidebarNavProps extends React.HTMLAttributes { + items: SidebarNavItem[] } export function SidebarNav({ className, items, ...props }: SidebarNavProps) { - const pathname = usePathname() + const pathname = usePathname() - return ( - - ) + {items.map((item) => { + const isActive = item.hrefRegex ? new RegExp(item.hrefRegex).test(pathname) : pathname === item.href; + + return ( + + {item.title} + + ) + })} + + ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/connections/[id]/page.tsx b/packages/web/src/app/[domain]/settings/connections/[id]/page.tsx new file mode 100644 index 00000000..774d4cbe --- /dev/null +++ b/packages/web/src/app/[domain]/settings/connections/[id]/page.tsx @@ -0,0 +1,204 @@ +import { sew } from "@/actions"; +import { BackButton } from "@/app/[domain]/components/backButton"; +import { DisplayDate } from "@/app/[domain]/components/DisplayDate"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { env } from "@/env.mjs"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { notFound, ServiceErrorException } from "@/lib/serviceError"; +import { CodeHostType, isServiceError } from "@/lib/utils"; +import { withAuthV2 } from "@/withAuthV2"; +import { AzureDevOpsConnectionConfig, BitbucketConnectionConfig, GenericGitHostConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GithubConnectionConfig, GitlabConnectionConfig } from "@sourcebot/schemas/v3/index.type"; +import { getConfigSettings } from "@sourcebot/shared"; +import { Info } from "lucide-react"; +import Link from "next/link"; +import { Suspense } from "react"; +import { ConnectionJobsTable } from "../components/connectionJobsTable"; + +interface ConnectionDetailPageProps { + params: Promise<{ + id: string + }> +} + + +export default async function ConnectionDetailPage(props: ConnectionDetailPageProps) { + const params = await props.params; + const { id } = params; + + const connection = await getConnectionWithJobs(Number.parseInt(id)); + if (isServiceError(connection)) { + throw new ServiceErrorException(connection); + } + + const configSettings = await getConfigSettings(env.CONFIG_PATH); + + const nextSyncAttempt = (() => { + const latestJob = connection.syncJobs.length > 0 ? connection.syncJobs[0] : null; + if (!latestJob) { + return undefined; + } + + if (latestJob.completedAt) { + return new Date(latestJob.completedAt.getTime() + configSettings.resyncConnectionIntervalMs); + } + + return undefined; + })(); + + const codeHostUrl = (() => { + const connectionType = connection.connectionType as CodeHostType; + switch (connectionType) { + case 'github': { + const config = connection.config as unknown as GithubConnectionConfig; + return config.url ?? 'https://github.com'; + } + case 'gitlab': { + const config = connection.config as unknown as GitlabConnectionConfig; + return config.url ?? 'https://gitlab.com'; + } + case 'gitea': { + const config = connection.config as unknown as GiteaConnectionConfig; + return config.url ?? 'https://gitea.com'; + } + case 'gerrit': { + const config = connection.config as unknown as GerritConnectionConfig; + return config.url; + } + case 'bitbucket-server': { + const config = connection.config as unknown as BitbucketConnectionConfig; + return config.url!; + } + case 'bitbucket-cloud': { + const config = connection.config as unknown as BitbucketConnectionConfig; + return config.url ?? 'https://bitbucket.org'; + } + case 'azuredevops': { + const config = connection.config as unknown as AzureDevOpsConnectionConfig; + return config.url ?? 'https://dev.azure.com'; + } + case 'generic-git-host': { + const config = connection.config as unknown as GenericGitHostConnectionConfig; + return config.url; + } + } + })(); + + return ( +
+ +
+

{connection.name}

+ + + {codeHostUrl} + +
+ +
+ + + + Created + + + + + +

When this connection was first added to Sourcebot

+
+
+
+
+ + + +
+ + + + + Last synced + + + + + +

The last time this connection was successfully synced

+
+
+
+
+ + {connection.syncedAt ? : "Never"} + +
+ + + + + Scheduled + + + + + +

When the next sync job is scheduled to run

+
+
+
+
+ + {nextSyncAttempt ? : "-"} + +
+
+ + + + Sync History + History of all sync jobs for this connection. + + + }> + + + + +
+ ) +} + +const getConnectionWithJobs = async (id: number) => sew(() => + withAuthV2(async ({ prisma, org }) => { + const connection = await prisma.connection.findUnique({ + where: { + id, + orgId: org.id, + }, + include: { + syncJobs: { + orderBy: { + createdAt: 'desc', + }, + }, + }, + }); + + if (!connection) { + return notFound(); + } + + return connection; + }) +) \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/connections/components/connectionJobsTable.tsx b/packages/web/src/app/[domain]/settings/connections/components/connectionJobsTable.tsx new file mode 100644 index 00000000..fec991cb --- /dev/null +++ b/packages/web/src/app/[domain]/settings/connections/components/connectionJobsTable.tsx @@ -0,0 +1,311 @@ +"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, AlertTriangle, ArrowUpDown, RefreshCwIcon } from "lucide-react" +import * as React from "react" +import { CopyIconButton } from "@/app/[domain]/components/copyIconButton" +import { useMemo } from "react" +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter" +import { useRouter } from "next/navigation" +import { useToast } from "@/components/hooks/use-toast" +import { DisplayDate } from "@/app/[domain]/components/DisplayDate" + + +export type ConnectionSyncJob = { + id: string + status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" + createdAt: Date + updatedAt: Date + completedAt: Date | null + errorMessage: string | null + warningMessages: string[] +} + +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: ConnectionSyncJob["status"]) => { + const labels = { + PENDING: "Pending", + IN_PROGRESS: "In Progress", + COMPLETED: "Completed", + FAILED: "Failed", + } + + return {labels[status]} +} + +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: "status", + header: "Status", + cell: ({ row }) => { + const job = row.original + return ( +
+ {getStatusBadge(row.getValue("status"))} + {job.errorMessage ? ( + + + + + + + + {job.errorMessage} + + + + + ) : job.warningMessages.length > 0 ? ( + + + + + + +

{job.warningMessages.length} warning(s) while syncing:

+
+ {job.warningMessages.map((warning, index) => ( +
+ {index + 1}. + {warning} +
+ ))} +
+
+
+
+ ) : null} +
+ ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => , + }, + { + accessorKey: "completedAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const completedAt = row.getValue("completedAt") as Date | null; + if (!completedAt) { + return "-"; + } + + return + }, + }, + { + 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 ConnectionJobsTable = ({ data }: { data: ConnectionSyncJob[] }) => { + const [sorting, setSorting] = React.useState([{ id: "createdAt", desc: true }]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const router = useRouter(); + const { toast } = useToast(); + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + sorting, + columnFilters, + columnVisibility, + }, + }) + + const { + numCompleted, + numInProgress, + numPending, + numFailed, + } = useMemo(() => { + return { + numCompleted: data.filter((job) => job.status === "COMPLETED").length, + numInProgress: data.filter((job) => job.status === "IN_PROGRESS").length, + numPending: data.filter((job) => job.status === "PENDING").length, + numFailed: data.filter((job) => job.status === "FAILED").length, + }; + }, [data]); + + 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 sync jobs found. + + + )} + +
+
+ +
+
+ {table.getFilteredRowModel().rows.length} job(s) total +
+
+ + +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx b/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx new file mode 100644 index 00000000..299a012c --- /dev/null +++ b/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx @@ -0,0 +1,279 @@ +"use client" + +import { DisplayDate } from "@/app/[domain]/components/DisplayDate" +import { useToast } from "@/components/hooks/use-toast" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +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 { CodeHostType, getCodeHostIcon } 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, RefreshCwIcon } from "lucide-react" +import Image from "next/image" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useMemo, useState } from "react" + + +export type Connection = { + id: number + name: string + syncedAt: Date | null + codeHostType: CodeHostType + 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: Connection["latestJobStatus"]) => { + if (!status) { + return No Jobs + } + + const labels = { + PENDING: "Pending", + IN_PROGRESS: "In Progress", + COMPLETED: "Completed", + FAILED: "Failed", + } + + return {labels[status]} +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + size: 400, + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const connection = row.original; + const codeHostIcon = getCodeHostIcon(connection.codeHostType); + + return ( +
+ {`${connection.codeHostType} + + {connection.name} + +
+ ) + }, + }, + { + accessorKey: "latestJobStatus", + size: 150, + header: "Lastest status", + cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")), + }, + { + accessorKey: "syncedAt", + size: 200, + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const syncedAt = row.getValue("syncedAt") as Date | null; + if (!syncedAt) { + return "-"; + } + + return ( + + ) + } + }, +] + +export const ConnectionsTable = ({ data }: { data: Connection[] }) => { + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const router = useRouter(); + const { toast } = useToast(); + + const { + numCompleted, + numInProgress, + numPending, + numFailed, + numNoJobs, + } = useMemo(() => { + return { + numCompleted: data.filter((connection) => connection.latestJobStatus === "COMPLETED").length, + numInProgress: data.filter((connection) => connection.latestJobStatus === "IN_PROGRESS").length, + numPending: data.filter((connection) => connection.latestJobStatus === "PENDING").length, + numFailed: data.filter((connection) => connection.latestJobStatus === "FAILED").length, + numNoJobs: data.filter((connection) => connection.latestJobStatus === null).length, + } + }, [data]); + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + columnResizeMode: 'onChange', + enableColumnResizing: false, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }) + + return ( +
+
+ table.getColumn("name")?.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 ? 'connections' : 'connection'} total +
+
+ + +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/settings/connections/layout.tsx b/packages/web/src/app/[domain]/settings/connections/layout.tsx new file mode 100644 index 00000000..0143b4e8 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/connections/layout.tsx @@ -0,0 +1,39 @@ +import { getMe } from "@/actions"; +import { getOrgFromDomain } from "@/data/org"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { notFound } from "next/navigation"; +import { isServiceError } from "@/lib/utils"; +import { OrgRole } from "@sourcebot/db"; + + +interface ConnectionsLayoutProps { + children: React.ReactNode; + params: Promise<{ + domain: string + }>; +} + +export default async function ConnectionsLayout({ children, params }: ConnectionsLayoutProps) { + const { domain } = await params; + + const org = await getOrgFromDomain(domain); + if (!org) { + throw new Error("Organization not found"); + } + + const me = await getMe(); + if (isServiceError(me)) { + throw new ServiceErrorException(me); + } + + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; + if (!userRoleInOrg) { + throw new Error("User role not found"); + } + + if (userRoleInOrg !== OrgRole.OWNER) { + return notFound(); + } + + return children; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/connections/page.tsx b/packages/web/src/app/[domain]/settings/connections/page.tsx new file mode 100644 index 00000000..30d803a5 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/connections/page.tsx @@ -0,0 +1,49 @@ +import { sew } from "@/actions"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { CodeHostType, isServiceError } from "@/lib/utils"; +import { withAuthV2 } from "@/withAuthV2"; +import Link from "next/link"; +import { ConnectionsTable } from "./components/connectionsTable"; + +const DOCS_URL = "https://docs.sourcebot.dev/docs/connections/overview"; + +export default async function ConnectionsPage() { + const connections = await getConnectionsWithLatestJob(); + if (isServiceError(connections)) { + throw new ServiceErrorException(connections); + } + + return ( +
+
+

Code Host Connections

+

Manage your connections to external code hosts. Learn more

+
+ ({ + id: connection.id, + name: connection.name, + codeHostType: connection.connectionType as CodeHostType, + syncedAt: connection.syncedAt, + latestJobStatus: connection.syncJobs.length > 0 ? connection.syncJobs[0].status : null, + }))} /> +
+ ) +} + +const getConnectionsWithLatestJob = async () => sew(() => + withAuthV2(async ({ prisma }) => { + const connections = await prisma.connection.findMany({ + include: { + syncJobs: { + orderBy: { + createdAt: 'desc' + }, + take: 1 + } + }, + orderBy: { + name: 'asc' + } + }); + return connections; + })); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 7259746d..fcb11f66 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,8 +1,7 @@ import React from "react" import { Metadata } from "next" -import { SidebarNav } from "./components/sidebar-nav" +import { SidebarNav, SidebarNavItem } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" -import { Header } from "./components/header"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; @@ -64,7 +63,7 @@ export default async function SettingsLayout( numJoinRequests = requests.length; } - const sidebarNavItems = [ + const sidebarNavItems: SidebarNavItem[] = [ { title: "General", href: `/${domain}/settings`, @@ -94,6 +93,13 @@ export default async function SettingsLayout( ), href: `/${domain}/settings/members`, }] : []), + ...(userRoleInOrg === OrgRole.OWNER ? [ + { + title: "Connections", + href: `/${domain}/settings/connections`, + hrefRegex: `/${domain}/settings/connections(\/[^/]+)?$`, + } + ] : []), { title: "Secrets", href: `/${domain}/settings/secrets`, @@ -115,21 +121,23 @@ export default async function SettingsLayout( ] return ( -
+
-
-
-
-

Settings

-
-
- -
{children}
+
+
+
+
+

Settings

+
+
+ +
{children}
+
-
+
) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/secrets/components/importSecretCard.tsx b/packages/web/src/app/[domain]/settings/secrets/components/importSecretCard.tsx index 0972e861..f92e2712 100644 --- a/packages/web/src/app/[domain]/settings/secrets/components/importSecretCard.tsx +++ b/packages/web/src/app/[domain]/settings/secrets/components/importSecretCard.tsx @@ -27,7 +27,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => { { setSelectedCodeHost("github"); setIsImportSecretDialogOpen(true); @@ -35,7 +35,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => { /> { setSelectedCodeHost("gitlab"); setIsImportSecretDialogOpen(true); @@ -43,7 +43,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => { /> { setSelectedCodeHost("gitea"); setIsImportSecretDialogOpen(true); diff --git a/packages/web/src/features/chat/components/searchScopeIcon.tsx b/packages/web/src/features/chat/components/searchScopeIcon.tsx index 933471f4..67170dca 100644 --- a/packages/web/src/features/chat/components/searchScopeIcon.tsx +++ b/packages/web/src/features/chat/components/searchScopeIcon.tsx @@ -1,5 +1,5 @@ -import { cn, getCodeHostIcon } from "@/lib/utils"; -import { FolderIcon, LibraryBigIcon } from "lucide-react"; +import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils"; +import { LibraryBigIcon } from "lucide-react"; import Image from "next/image"; import { SearchScope } from "../types"; @@ -13,20 +13,16 @@ export const SearchScopeIcon = ({ searchScope, className = "h-4 w-4" }: SearchSc return ; } else { // Render code host icon for repos - const codeHostIcon = searchScope.codeHostType ? getCodeHostIcon(searchScope.codeHostType) : null; - if (codeHostIcon) { - const size = className.includes('h-3') ? 12 : 16; - return ( - {`${searchScope.codeHostType} - ); - } else { - return ; - } + const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType); + const size = className.includes('h-3') ? 12 : 16; + return ( + {`${searchScope.codeHostType} + ); } }; \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index f25206a5..bc4850f0 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -74,7 +74,7 @@ export type CodeHostType = "azuredevops" | "generic-git-host"; -export type AuthProviderType = +export type AuthProviderType = "github" | "gitlab" | "google" | @@ -105,7 +105,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => { }; case "gitlab": return { - id: "gitlab", + id: "gitlab", name: "GitLab", displayName: "GitLab", icon: { @@ -115,7 +115,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => { case "google": return { id: "google", - name: "Google", + name: "Google", displayName: "Google", icon: { src: googleLogo, @@ -125,7 +125,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => { return { id: "okta", name: "Okta", - displayName: "Okta", + displayName: "Okta", icon: { src: oktaLogo, className: "dark:invert", @@ -145,7 +145,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => { id: "microsoft-entra-id", name: "Microsoft Entra ID", displayName: "Microsoft Entra ID", - icon: { + icon: { src: microsoftLogo, }, }; @@ -283,7 +283,7 @@ export const getCodeHostInfoForRepo = (repo: { } } -export const getCodeHostIcon = (codeHostType: string): { src: string, className?: string } | null => { +export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } => { switch (codeHostType) { case "github": return { @@ -315,8 +315,6 @@ export const getCodeHostIcon = (codeHostType: string): { src: string, className? return { src: gitLogo, } - default: - return null; } } @@ -364,7 +362,7 @@ export const getCodeHostBrowseAtBranchUrl = ({ if (!webUrl) { return undefined; } - + switch (codeHostType) { case 'github': return `${webUrl}/tree/${branchName}`; @@ -416,7 +414,7 @@ export const getFormattedDate = (date: Date) => { const now = new Date(); const diffMinutes = (now.getTime() - date.getTime()) / (1000 * 60); const isFuture = diffMinutes < 0; - + // Use absolute values for calculations const minutes = Math.abs(diffMinutes); const hours = minutes / 60; @@ -426,7 +424,7 @@ export const getFormattedDate = (date: Date) => { const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month', isFuture: boolean) => { const roundedValue = Math.floor(value); const pluralUnit = roundedValue === 1 ? unit : `${unit}s`; - + if (isFuture) { return `In ${roundedValue} ${pluralUnit}`; } else { @@ -508,7 +506,7 @@ export const measure = async (cb: () => Promise, measureName: string, outp * @param promise The promise to unwrap. * @returns The data from the promise. */ -export const unwrapServiceError = async (promise: Promise): Promise => { +export const unwrapServiceError = async (promise: Promise): Promise => { const data = await promise; if (isServiceError(data)) { throw new Error(data.message); @@ -531,10 +529,10 @@ export const requiredQueryParamGuard = (request: NextRequest, param: string): Se export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): string | undefined => { if (!imageUrl) return undefined; - + try { const url = new URL(imageUrl); - + // List of known public instances that don't require authentication const publicHostnames = [ 'github.com', @@ -542,9 +540,9 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): s 'gitea.com', 'bitbucket.org', ]; - + const isPublicInstance = publicHostnames.includes(url.hostname); - + if (isPublicInstance) { return imageUrl; } else { @@ -566,8 +564,8 @@ export const IS_MAC = typeof navigator !== 'undefined' && /Mac OS X/.test(naviga export const isHttpError = (error: unknown, status: number): boolean => { - return error !== null + return error !== null && typeof error === 'object' - && 'status' in error + && 'status' in error && error.status === status; } \ No newline at end of file