mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +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 { 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,8 +41,7 @@ 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)
|
||||||
|
|
@ -60,7 +59,6 @@ export const DemoCards = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return <Code className={cn(sizeClass, colorClass)} />;
|
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 { 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: {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,11 +6,14 @@ 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) {
|
||||||
|
|
@ -24,13 +27,16 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
|
const isActive = item.hrefRegex ? new RegExp(item.hrefRegex).test(pathname) : pathname === item.href;
|
||||||
|
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
pathname === item.href
|
isActive
|
||||||
? "bg-muted hover:bg-muted"
|
? "bg-muted hover:bg-muted"
|
||||||
: "hover:bg-transparent hover:underline",
|
: "hover:bg-transparent hover:underline",
|
||||||
"justify-start"
|
"justify-start"
|
||||||
|
|
@ -38,7 +44,8 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</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 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,14 +121,15 @@ 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>
|
||||||
|
<div className="flex flex-row gap-10">
|
||||||
<aside className="lg:w-48">
|
<aside className="lg:w-48">
|
||||||
<SidebarNav items={sidebarNavItems} />
|
<SidebarNav items={sidebarNavItems} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -130,6 +137,7 @@ export default async function SettingsLayout(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,8 +13,7 @@ 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
|
||||||
|
|
@ -25,8 +24,5 @@ export const SearchScopeIcon = ({ searchScope, className = "h-4 w-4" }: SearchSc
|
||||||
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")} />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue