mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Add connection table
This commit is contained in:
parent
df0ca07f84
commit
40adbf856b
17 changed files with 1036 additions and 142 deletions
|
|
@ -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 (
|
||||
<Image
|
||||
src={codeHostIcon.src}
|
||||
alt={`${searchScope.codeHostType} icon`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn(sizeClass, selectedIconClass)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={codeHostIcon.src}
|
||||
alt={`${searchScope.codeHostType} icon`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn(sizeClass, selectedIconClass)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Code className={cn(sizeClass, colorClass)} />;
|
||||
|
|
|
|||
20
packages/web/src/app/[domain]/components/backButton.tsx
Normal file
20
packages/web/src/app/[domain]/components/backButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="container mx-auto">
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" asChild className="mb-4">
|
||||
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos`}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to repositories
|
||||
</Link>
|
||||
</Button>
|
||||
<BackButton
|
||||
href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos`}
|
||||
name="Back to repositories"
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
|
|
@ -168,7 +167,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
|||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Indexing Jobs</CardTitle>
|
||||
<CardTitle>Indexing History</CardTitle>
|
||||
<CardDescription>History of all indexing and cleanup jobs for this repository.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -177,16 +176,17 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
|||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
|
|||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table style={{ tableLayout: 'fixed', width: '100%' }}>
|
||||
<Table style={{ width: '100%' }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ export default async function Layout(
|
|||
<div className="min-h-screen flex flex-col">
|
||||
<NavigationMenu domain={domain} />
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default async function ReposPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
|
|
@ -31,12 +31,12 @@ export default async function ReposPage() {
|
|||
codeHostType: repo.external_codeHostType,
|
||||
indexedCommitHash: repo.indexedCommitHash,
|
||||
}))} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<HTMLElement> {
|
||||
items: {
|
||||
export type SidebarNavItem = {
|
||||
href: string
|
||||
hrefRegex?: string
|
||||
title: React.ReactNode
|
||||
}[]
|
||||
}
|
||||
|
||||
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
items: SidebarNavItem[]
|
||||
}
|
||||
|
||||
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||
const pathname = usePathname()
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col space-x-2 lg:space-x-0 lg:space-y-1",
|
||||
className
|
||||
)}
|
||||
{...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"
|
||||
)}
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col space-x-2 lg:space-x-0 lg:space-y-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
{items.map((item) => {
|
||||
const isActive = item.hrefRegex ? new RegExp(item.hrefRegex).test(pathname) : pathname === item.href;
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
204
packages/web/src/app/[domain]/settings/connections/[id]/page.tsx
Normal file
204
packages/web/src/app/[domain]/settings/connections/[id]/page.tsx
Normal 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;
|
||||
})
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
49
packages/web/src/app/[domain]/settings/connections/page.tsx
Normal file
49
packages/web/src/app/[domain]/settings/connections/page.tsx
Normal 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;
|
||||
}));
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex flex-col bg-backgroundSecondary">
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<NavigationMenu domain={domain} />
|
||||
<div className="flex-grow flex justify-center p-4 relative">
|
||||
<div className="w-full max-w-6xl p-6">
|
||||
<Header className="w-full">
|
||||
<h1 className="text-3xl">Settings</h1>
|
||||
</Header>
|
||||
<div className="flex flex-row gap-10 mt-20">
|
||||
<aside className="lg:w-48">
|
||||
<SidebarNav items={sidebarNavItems} />
|
||||
</aside>
|
||||
<div className="w-full rounded-lg">{children}</div>
|
||||
<main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
|
||||
<div className="w-full max-w-6xl rounded-lg p-6">
|
||||
<div className="container mx-auto">
|
||||
<div className="mb-16">
|
||||
<h1 className="text-3xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
<div className="flex flex-row gap-10">
|
||||
<aside className="lg:w-48">
|
||||
<SidebarNav items={sidebarNavItems} />
|
||||
</aside>
|
||||
<div className="w-full rounded-lg">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
|
|||
<CardContent className="flex flex-row gap-4 w-full justify-center">
|
||||
<CodeHostIconButton
|
||||
name="GitHub"
|
||||
logo={getCodeHostIcon("github")!}
|
||||
logo={getCodeHostIcon("github")}
|
||||
onClick={() => {
|
||||
setSelectedCodeHost("github");
|
||||
setIsImportSecretDialogOpen(true);
|
||||
|
|
@ -35,7 +35,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
|
|||
/>
|
||||
<CodeHostIconButton
|
||||
name="GitLab"
|
||||
logo={getCodeHostIcon("gitlab")!}
|
||||
logo={getCodeHostIcon("gitlab")}
|
||||
onClick={() => {
|
||||
setSelectedCodeHost("gitlab");
|
||||
setIsImportSecretDialogOpen(true);
|
||||
|
|
@ -43,7 +43,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
|
|||
/>
|
||||
<CodeHostIconButton
|
||||
name="Gitea"
|
||||
logo={getCodeHostIcon("gitea")!}
|
||||
logo={getCodeHostIcon("gitea")}
|
||||
onClick={() => {
|
||||
setSelectedCodeHost("gitea");
|
||||
setIsImportSecretDialogOpen(true);
|
||||
|
|
|
|||
|
|
@ -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 <LibraryBigIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
|
||||
} 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 (
|
||||
<Image
|
||||
src={codeHostIcon.src}
|
||||
alt={`${searchScope.codeHostType} icon`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn(className, "flex-shrink-0", codeHostIcon.className)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <FolderIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
|
||||
}
|
||||
const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType);
|
||||
const size = className.includes('h-3') ? 12 : 16;
|
||||
return (
|
||||
<Image
|
||||
src={codeHostIcon.src}
|
||||
alt={`${searchScope.codeHostType} icon`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn(className, "flex-shrink-0", codeHostIcon.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 <T>(cb: () => Promise<T>, measureName: string, outp
|
|||
* @param promise The promise to unwrap.
|
||||
* @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;
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue