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 (
-
- );
- }
+ return (
+
+ );
}
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(
)
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.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 (
-
+
-
-
)
}
\ 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 (
-
- );
- } else {
- return ;
- }
+ const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType);
+ const size = className.includes('h-3') ? 12 : 16;
+ return (
+
+ );
}
};
\ 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