Add connection table

This commit is contained in:
bkellam 2025-10-28 15:14:02 -07:00
parent df0ca07f84
commit 40adbf856b
17 changed files with 1036 additions and 142 deletions

View file

@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { DemoExamples, DemoSearchExample, DemoSearchScope } from "@/types"; 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 useCaptureEvent from "@/hooks/useCaptureEvent";
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard"; import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
@ -41,25 +41,23 @@ export const DemoCards = ({
} }
if (searchScope.codeHostType) { if (searchScope.codeHostType) {
const codeHostIcon = getCodeHostIcon(searchScope.codeHostType); const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType);
if (codeHostIcon) { // When selected, icons need to match the inverted badge colors
// When selected, icons need to match the inverted badge colors // In light mode selected: light icon on dark bg (invert)
// In light mode selected: light icon on dark bg (invert) // In dark mode selected: dark icon on light bg (no invert, override dark:invert)
// In dark mode selected: dark icon on light bg (no invert, override dark:invert) const selectedIconClass = isSelected
const selectedIconClass = isSelected ? "invert dark:invert-0"
? "invert dark:invert-0" : codeHostIcon.className;
: codeHostIcon.className;
return ( return (
<Image <Image
src={codeHostIcon.src} src={codeHostIcon.src}
alt={`${searchScope.codeHostType} icon`} alt={`${searchScope.codeHostType} icon`}
width={size} width={size}
height={size} height={size}
className={cn(sizeClass, selectedIconClass)} className={cn(sizeClass, selectedIconClass)}
/> />
); );
}
} }
return <Code className={cn(sizeClass, colorClass)} />; return <Code className={cn(sizeClass, colorClass)} />;

View file

@ -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 (
<Link href={href} className={cn("inline-flex items-center text-link transition-colors group", className)}>
<span className="inline-flex items-center gap-1.5 border-b border-transparent group-hover:border-link pb-0.5">
<ArrowLeft className="h-4 w-4" />
<span>{name}</span>
</span>
</Link>
)
}

View file

@ -4,21 +4,21 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { env } from "@/env.mjs"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { ServiceErrorException } from "@/lib/serviceError" import { ServiceErrorException } from "@/lib/serviceError"
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils" import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"
import { withOptionalAuthV2 } from "@/withAuthV2" 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 Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { RepoJobsTable } from "../components/repoJobsTable" import { BackButton } from "../../components/backButton"
import { getConfigSettings } from "@sourcebot/shared"
import { env } from "@/env.mjs"
import { DisplayDate } from "../../components/DisplayDate" import { DisplayDate } from "../../components/DisplayDate"
import { RepoBranchesTable } from "../components/repoBranchesTable" 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 }> }) { export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
@ -52,14 +52,13 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
const repoMetadata = repoMetadataSchema.parse(repo.metadata); const repoMetadata = repoMetadataSchema.parse(repo.metadata);
return ( return (
<div className="container mx-auto"> <>
<div className="mb-6"> <div className="mb-6">
<Button variant="ghost" asChild className="mb-4"> <BackButton
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos`}> href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos`}
<ChevronLeft className="mr-2 h-4 w-4" /> name="Back to repositories"
Back to repositories className="mb-2"
</Link> />
</Button>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
@ -168,7 +167,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Indexing Jobs</CardTitle> <CardTitle>Indexing History</CardTitle>
<CardDescription>History of all indexing and cleanup jobs for this repository.</CardDescription> <CardDescription>History of all indexing and cleanup jobs for this repository.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -177,16 +176,17 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
</Suspense> </Suspense>
</CardContent> </CardContent>
</Card> </Card>
</div> </>
) )
} }
const getRepoWithJobs = async (repoId: number) => sew(() => const getRepoWithJobs = async (repoId: number) => sew(() =>
withOptionalAuthV2(async ({ prisma }) => { withOptionalAuthV2(async ({ prisma, org }) => {
const repo = await prisma.repo.findUnique({ const repo = await prisma.repo.findUnique({
where: { where: {
id: repoId, id: repoId,
orgId: org.id,
}, },
include: { include: {
jobs: { jobs: {

View file

@ -331,7 +331,7 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
</Button> </Button>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border">
<Table style={{ tableLayout: 'fixed', width: '100%' }}> <Table style={{ width: '100%' }}>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>

View file

@ -16,7 +16,11 @@ export default async function Layout(
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<NavigationMenu domain={domain} /> <NavigationMenu domain={domain} />
<main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative"> <main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div> <div className="w-full max-w-6xl rounded-lg p-6">
<div className="container mx-auto">
{children}
</div>
</div>
</main> </main>
</div> </div>
) )

View file

@ -12,7 +12,7 @@ export default async function ReposPage() {
} }
return ( return (
<div className="container mx-auto"> <>
<div className="mb-6"> <div className="mb-6">
<h1 className="text-3xl font-semibold">Repositories</h1> <h1 className="text-3xl font-semibold">Repositories</h1>
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p> <p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
@ -31,12 +31,12 @@ export default async function ReposPage() {
codeHostType: repo.external_codeHostType, codeHostType: repo.external_codeHostType,
indexedCommitHash: repo.indexedCommitHash, indexedCommitHash: repo.indexedCommitHash,
}))} /> }))} />
</div> </>
) )
} }
const getReposWithLatestJob = async () => sew(() => const getReposWithLatestJob = async () => sew(() =>
withOptionalAuthV2(async ({ prisma }) => { withOptionalAuthV2(async ({ prisma, org }) => {
const repos = await prisma.repo.findMany({ const repos = await prisma.repo.findMany({
include: { include: {
jobs: { jobs: {
@ -48,6 +48,9 @@ const getReposWithLatestJob = async () => sew(() =>
}, },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
},
where: {
orgId: org.id,
} }
}); });
return repos; return repos;

View file

@ -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 (
<div className={cn("mb-16", className)}>
{children}
<Separator className={clsx("absolute left-0 right-0", { "mt-12": withTopMargin })} />
</div>
)
}

View file

@ -6,39 +6,46 @@ import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> { export type SidebarNavItem = {
items: {
href: string href: string
hrefRegex?: string
title: React.ReactNode title: React.ReactNode
}[] }
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: SidebarNavItem[]
} }
export function SidebarNav({ className, items, ...props }: SidebarNavProps) { export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
const pathname = usePathname() const pathname = usePathname()
return ( return (
<nav <nav
className={cn( className={cn(
"flex flex-col space-x-2 lg:space-x-0 lg:space-y-1", "flex flex-col space-x-2 lg:space-x-0 lg:space-y-1",
className className
)} )}
{...props} {...props}
>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === item.href
? "bg-muted hover:bg-muted"
: "hover:bg-transparent hover:underline",
"justify-start"
)}
> >
{item.title} {items.map((item) => {
</Link> const isActive = item.hrefRegex ? new RegExp(item.hrefRegex).test(pathname) : pathname === item.href;
))}
</nav> return (
) <Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
isActive
? "bg-muted hover:bg-muted"
: "hover:bg-transparent hover:underline",
"justify-start"
)}
>
{item.title}
</Link>
)
})}
</nav>
)
} }

View file

@ -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 (
<div>
<BackButton
href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections`}
name="Back to connections"
className="mb-2"
/>
<div className="flex flex-col gap-2 mb-6">
<h1 className="text-3xl font-semibold">{connection.name}</h1>
<Link
href={codeHostUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-muted-foreground"
>
{codeHostUrl}
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3 mb-8">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
Created
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>When this connection was first added to Sourcebot</p>
</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent>
<DisplayDate date={connection.createdAt} className="text-2xl font-semibold" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
Last synced
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>The last time this connection was successfully synced</p>
</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent>
{connection.syncedAt ? <DisplayDate date={connection.syncedAt} className="text-2xl font-semibold" /> : "Never"}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
Scheduled
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>When the next sync job is scheduled to run</p>
</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent>
{nextSyncAttempt ? <DisplayDate date={nextSyncAttempt} className="text-2xl font-semibold" /> : "-"}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Sync History</CardTitle>
<CardDescription>History of all sync jobs for this connection.</CardDescription>
</CardHeader>
<CardContent>
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
<ConnectionJobsTable data={connection.syncJobs} />
</Suspense>
</CardContent>
</Card>
</div>
)
}
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;
})
)

View file

@ -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 <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
}
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<ConnectionSyncJob>[] = [
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const job = row.original
return (
<div className="flex items-center gap-2">
{getStatusBadge(row.getValue("status"))}
{job.errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertCircle className="h-4 w-4 text-destructive" />
</TooltipTrigger>
<TooltipContent className="max-w-[750px] max-h-96 overflow-scroll p-4">
<LightweightCodeHighlighter
language="text"
lineNumbers={true}
renderWhitespace={false}
>
{job.errorMessage}
</LightweightCodeHighlighter>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : job.warningMessages.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className="h-4 w-4 text-warning" />
</TooltipTrigger>
<TooltipContent className="max-w-[750px] max-h-96 overflow-scroll p-4">
<p className="text-sm font-medium mb-2">{job.warningMessages.length} warning(s) while syncing:</p>
<div className="flex flex-col gap-1">
{job.warningMessages.map((warning, index) => (
<div
key={index}
className="text-sm font-mono flex flex-row items-center gap-1.5"
>
<span>{index + 1}.</span>
<span className="text-warning">{warning}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Started
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => <DisplayDate date={row.getValue("createdAt") as Date} className="ml-3" />,
},
{
accessorKey: "completedAt",
header: ({ column }) => {
return (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Completed
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const completedAt = row.getValue("completedAt") as Date | null;
if (!completedAt) {
return "-";
}
return <DisplayDate date={completedAt} className="ml-3" />
},
},
{
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 (
<div className="flex items-center gap-2">
<code className="text-xs text-muted-foreground">{id}</code>
<CopyIconButton onCopy={() => {
navigator.clipboard.writeText(id);
return true;
}} />
</div>
)
},
},
]
export const ConnectionJobsTable = ({ data }: { data: ConnectionSyncJob[] }) => {
const [sorting, setSorting] = React.useState<SortingState>([{ id: "createdAt", desc: true }])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
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 (
<div className="w-full">
<div className="flex items-center gap-4 py-4">
<Select
value={(table.getColumn("status")?.getFilterValue() as string) ?? "all"}
onValueChange={(value) => table.getColumn("status")?.setFilterValue(value === "all" ? "" : value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Filter by status</SelectItem>
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
className="ml-auto"
onClick={() => {
router.refresh();
toast({
description: "Page refreshed",
});
}}
>
<RefreshCwIcon className="w-3 h-3" />
Refresh
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No sync jobs found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} job(s) total
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</Button>
</div>
</div>
</div>
)
}

View file

@ -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 <Badge className={statusBadgeVariants({ status: "NO_JOBS" })}>No Jobs</Badge>
}
const labels = {
PENDING: "Pending",
IN_PROGRESS: "In Progress",
COMPLETED: "Completed",
FAILED: "Failed",
}
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
}
export const columns: ColumnDef<Connection>[] = [
{
accessorKey: "name",
size: 400,
header: ({ column }) => {
return (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const connection = row.original;
const codeHostIcon = getCodeHostIcon(connection.codeHostType);
return (
<div className="flex flex-row gap-2 items-center">
<Image
src={codeHostIcon.src}
alt={`${connection.codeHostType} logo`}
width={20}
height={20}
/>
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections/${connection.id}`} className="font-medium hover:underline">
{connection.name}
</Link>
</div>
)
},
},
{
accessorKey: "latestJobStatus",
size: 150,
header: "Lastest status",
cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")),
},
{
accessorKey: "syncedAt",
size: 200,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Last synced
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const syncedAt = row.getValue("syncedAt") as Date | null;
if (!syncedAt) {
return "-";
}
return (
<DisplayDate date={syncedAt} className="ml-3" />
)
}
},
]
export const ConnectionsTable = ({ data }: { data: Connection[] }) => {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
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 (
<div className="w-full">
<div className="flex items-center gap-4 py-4">
<Input
placeholder="Filter connections..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
<Select
value={(table.getColumn("latestJobStatus")?.getFilterValue() as string) ?? "all"}
onValueChange={(value) => {
table.getColumn("latestJobStatus")?.setFilterValue(value === "all" ? "" : value)
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Filter by status</SelectItem>
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
<SelectItem value="null">No status ({numNoJobs})</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
className="ml-auto"
onClick={() => {
router.refresh();
toast({
description: "Page refreshed",
});
}}
>
<RefreshCwIcon className="w-3 h-3" />
Refresh
</Button>
</div>
<div className="rounded-md border">
<Table style={{ width: '100%' }}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{ width: `${header.getSize()}px` }}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: `${cell.column.getSize()}px` }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} {data.length > 1 ? 'connections' : 'connection'} total
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</Button>
</div>
</div>
</div>
)
}

View file

@ -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;
}

View file

@ -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 (
<div className="flex flex-col gap-6">
<div>
<h3 className="text-lg font-medium">Code Host Connections</h3>
<p className="text-sm text-muted-foreground">Manage your connections to external code hosts. <Link href={DOCS_URL} target="_blank" className="text-link hover:underline">Learn more</Link></p>
</div>
<ConnectionsTable data={connections.map((connection) => ({
id: connection.id,
name: connection.name,
codeHostType: connection.connectionType as CodeHostType,
syncedAt: connection.syncedAt,
latestJobStatus: connection.syncJobs.length > 0 ? connection.syncJobs[0].status : null,
}))} />
</div>
)
}
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;
}));

View file

@ -1,8 +1,7 @@
import React from "react" import React from "react"
import { Metadata } from "next" import { Metadata } from "next"
import { SidebarNav } from "./components/sidebar-nav" import { SidebarNav, SidebarNavItem } from "./components/sidebar-nav"
import { NavigationMenu } from "../components/navigationMenu" import { NavigationMenu } from "../components/navigationMenu"
import { Header } from "./components/header";
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
@ -64,7 +63,7 @@ export default async function SettingsLayout(
numJoinRequests = requests.length; numJoinRequests = requests.length;
} }
const sidebarNavItems = [ const sidebarNavItems: SidebarNavItem[] = [
{ {
title: "General", title: "General",
href: `/${domain}/settings`, href: `/${domain}/settings`,
@ -94,6 +93,13 @@ export default async function SettingsLayout(
), ),
href: `/${domain}/settings/members`, href: `/${domain}/settings/members`,
}] : []), }] : []),
...(userRoleInOrg === OrgRole.OWNER ? [
{
title: "Connections",
href: `/${domain}/settings/connections`,
hrefRegex: `/${domain}/settings/connections(\/[^/]+)?$`,
}
] : []),
{ {
title: "Secrets", title: "Secrets",
href: `/${domain}/settings/secrets`, href: `/${domain}/settings/secrets`,
@ -115,21 +121,23 @@ export default async function SettingsLayout(
] ]
return ( return (
<div className="min-h-screen flex flex-col bg-backgroundSecondary"> <div className="min-h-screen flex flex-col">
<NavigationMenu domain={domain} /> <NavigationMenu domain={domain} />
<div className="flex-grow flex justify-center p-4 relative"> <main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
<div className="w-full max-w-6xl p-6"> <div className="w-full max-w-6xl rounded-lg p-6">
<Header className="w-full"> <div className="container mx-auto">
<h1 className="text-3xl">Settings</h1> <div className="mb-16">
</Header> <h1 className="text-3xl font-semibold">Settings</h1>
<div className="flex flex-row gap-10 mt-20"> </div>
<aside className="lg:w-48"> <div className="flex flex-row gap-10">
<SidebarNav items={sidebarNavItems} /> <aside className="lg:w-48">
</aside> <SidebarNav items={sidebarNavItems} />
<div className="w-full rounded-lg">{children}</div> </aside>
<div className="w-full rounded-lg">{children}</div>
</div>
</div> </div>
</div> </div>
</div> </main>
</div> </div>
) )
} }

View file

@ -27,7 +27,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
<CardContent className="flex flex-row gap-4 w-full justify-center"> <CardContent className="flex flex-row gap-4 w-full justify-center">
<CodeHostIconButton <CodeHostIconButton
name="GitHub" name="GitHub"
logo={getCodeHostIcon("github")!} logo={getCodeHostIcon("github")}
onClick={() => { onClick={() => {
setSelectedCodeHost("github"); setSelectedCodeHost("github");
setIsImportSecretDialogOpen(true); setIsImportSecretDialogOpen(true);
@ -35,7 +35,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
/> />
<CodeHostIconButton <CodeHostIconButton
name="GitLab" name="GitLab"
logo={getCodeHostIcon("gitlab")!} logo={getCodeHostIcon("gitlab")}
onClick={() => { onClick={() => {
setSelectedCodeHost("gitlab"); setSelectedCodeHost("gitlab");
setIsImportSecretDialogOpen(true); setIsImportSecretDialogOpen(true);
@ -43,7 +43,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
/> />
<CodeHostIconButton <CodeHostIconButton
name="Gitea" name="Gitea"
logo={getCodeHostIcon("gitea")!} logo={getCodeHostIcon("gitea")}
onClick={() => { onClick={() => {
setSelectedCodeHost("gitea"); setSelectedCodeHost("gitea");
setIsImportSecretDialogOpen(true); setIsImportSecretDialogOpen(true);

View file

@ -1,5 +1,5 @@
import { cn, getCodeHostIcon } from "@/lib/utils"; import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils";
import { FolderIcon, LibraryBigIcon } from "lucide-react"; import { LibraryBigIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { SearchScope } from "../types"; import { SearchScope } from "../types";
@ -13,20 +13,16 @@ export const SearchScopeIcon = ({ searchScope, className = "h-4 w-4" }: SearchSc
return <LibraryBigIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />; return <LibraryBigIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
} else { } else {
// Render code host icon for repos // Render code host icon for repos
const codeHostIcon = searchScope.codeHostType ? getCodeHostIcon(searchScope.codeHostType) : null; const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType);
if (codeHostIcon) { const size = className.includes('h-3') ? 12 : 16;
const size = className.includes('h-3') ? 12 : 16; return (
return ( <Image
<Image src={codeHostIcon.src}
src={codeHostIcon.src} alt={`${searchScope.codeHostType} icon`}
alt={`${searchScope.codeHostType} icon`} width={size}
width={size} height={size}
height={size} className={cn(className, "flex-shrink-0", codeHostIcon.className)}
className={cn(className, "flex-shrink-0", codeHostIcon.className)} />
/> );
);
} else {
return <FolderIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
}
} }
}; };

View file

@ -74,7 +74,7 @@ export type CodeHostType =
"azuredevops" | "azuredevops" |
"generic-git-host"; "generic-git-host";
export type AuthProviderType = export type AuthProviderType =
"github" | "github" |
"gitlab" | "gitlab" |
"google" | "google" |
@ -105,7 +105,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => {
}; };
case "gitlab": case "gitlab":
return { return {
id: "gitlab", id: "gitlab",
name: "GitLab", name: "GitLab",
displayName: "GitLab", displayName: "GitLab",
icon: { icon: {
@ -115,7 +115,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => {
case "google": case "google":
return { return {
id: "google", id: "google",
name: "Google", name: "Google",
displayName: "Google", displayName: "Google",
icon: { icon: {
src: googleLogo, src: googleLogo,
@ -125,7 +125,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => {
return { return {
id: "okta", id: "okta",
name: "Okta", name: "Okta",
displayName: "Okta", displayName: "Okta",
icon: { icon: {
src: oktaLogo, src: oktaLogo,
className: "dark:invert", className: "dark:invert",
@ -145,7 +145,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => {
id: "microsoft-entra-id", id: "microsoft-entra-id",
name: "Microsoft Entra ID", name: "Microsoft Entra ID",
displayName: "Microsoft Entra ID", displayName: "Microsoft Entra ID",
icon: { icon: {
src: microsoftLogo, 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) { switch (codeHostType) {
case "github": case "github":
return { return {
@ -315,8 +315,6 @@ export const getCodeHostIcon = (codeHostType: string): { src: string, className?
return { return {
src: gitLogo, src: gitLogo,
} }
default:
return null;
} }
} }
@ -364,7 +362,7 @@ export const getCodeHostBrowseAtBranchUrl = ({
if (!webUrl) { if (!webUrl) {
return undefined; return undefined;
} }
switch (codeHostType) { switch (codeHostType) {
case 'github': case 'github':
return `${webUrl}/tree/${branchName}`; return `${webUrl}/tree/${branchName}`;
@ -416,7 +414,7 @@ export const getFormattedDate = (date: Date) => {
const now = new Date(); const now = new Date();
const diffMinutes = (now.getTime() - date.getTime()) / (1000 * 60); const diffMinutes = (now.getTime() - date.getTime()) / (1000 * 60);
const isFuture = diffMinutes < 0; const isFuture = diffMinutes < 0;
// Use absolute values for calculations // Use absolute values for calculations
const minutes = Math.abs(diffMinutes); const minutes = Math.abs(diffMinutes);
const hours = minutes / 60; 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 formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month', isFuture: boolean) => {
const roundedValue = Math.floor(value); const roundedValue = Math.floor(value);
const pluralUnit = roundedValue === 1 ? unit : `${unit}s`; const pluralUnit = roundedValue === 1 ? unit : `${unit}s`;
if (isFuture) { if (isFuture) {
return `In ${roundedValue} ${pluralUnit}`; return `In ${roundedValue} ${pluralUnit}`;
} else { } else {
@ -508,7 +506,7 @@ export const measure = async <T>(cb: () => Promise<T>, measureName: string, outp
* @param promise The promise to unwrap. * @param promise The promise to unwrap.
* @returns The data from the promise. * @returns The data from the promise.
*/ */
export const unwrapServiceError = async <T>(promise: Promise<ServiceError | T>): Promise<T> => { export const unwrapServiceError = async <T>(promise: Promise<ServiceError | T>): Promise<T> => {
const data = await promise; const data = await promise;
if (isServiceError(data)) { if (isServiceError(data)) {
throw new Error(data.message); 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 => { export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): string | undefined => {
if (!imageUrl) return undefined; if (!imageUrl) return undefined;
try { try {
const url = new URL(imageUrl); const url = new URL(imageUrl);
// List of known public instances that don't require authentication // List of known public instances that don't require authentication
const publicHostnames = [ const publicHostnames = [
'github.com', 'github.com',
@ -542,9 +540,9 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): s
'gitea.com', 'gitea.com',
'bitbucket.org', 'bitbucket.org',
]; ];
const isPublicInstance = publicHostnames.includes(url.hostname); const isPublicInstance = publicHostnames.includes(url.hostname);
if (isPublicInstance) { if (isPublicInstance) {
return imageUrl; return imageUrl;
} else { } 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 => { export const isHttpError = (error: unknown, status: number): boolean => {
return error !== null return error !== null
&& typeof error === 'object' && typeof error === 'object'
&& 'status' in error && 'status' in error
&& error.status === status; && error.status === status;
} }