delete connections components

This commit is contained in:
bkellam 2025-10-16 23:07:34 -07:00
parent 88705f5e7e
commit cfb359351d
21 changed files with 0 additions and 2206 deletions

View file

@ -1,110 +0,0 @@
"use client"
import { BackendError } from "@sourcebot/error";
import { Prisma } from "@sourcebot/db";
export function DisplayConnectionError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
const errorCode = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'error' in syncStatusMetadata
? (syncStatusMetadata.error as string)
: undefined;
switch (errorCode) {
case BackendError.CONNECTION_SYNC_INVALID_TOKEN:
return <InvalidTokenError syncStatusMetadata={syncStatusMetadata} onSecretsClick={onSecretsClick} />
case BackendError.CONNECTION_SYNC_SECRET_DNE:
return <SecretNotFoundError syncStatusMetadata={syncStatusMetadata} onSecretsClick={onSecretsClick} />
case BackendError.CONNECTION_SYNC_SYSTEM_ERROR:
return <SystemError />
case BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS:
return <FailedToFetchGerritProjects syncStatusMetadata={syncStatusMetadata} />
default:
return <UnknownError />
}
}
function SecretNotFoundError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
const secretKey = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'secretKey' in syncStatusMetadata
? (syncStatusMetadata.secretKey as string)
: undefined;
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Secret Not Found</h4>
<p className="text-sm text-muted-foreground">
The secret key provided for this connection was not found. Please ensure your config is referencing a secret
that exists in your{" "}
<button onClick={onSecretsClick} className="text-primary hover:underline">
organization&apos;s secrets
</button>
, and try again.
</p>
{secretKey && (
<p className="text-sm text-muted-foreground">
Secret Key: <span className="text-red-500">{secretKey}</span>
</p>
)}
</div>
);
}
function InvalidTokenError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
const secretKey = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'secretKey' in syncStatusMetadata
? (syncStatusMetadata.secretKey as string)
: undefined;
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Invalid Authentication Token</h4>
<p className="text-sm text-muted-foreground">
The authentication token provided for this connection is invalid. Please update your config with a valid token and try again.
</p>
{secretKey && (
<p className="text-sm text-muted-foreground">
Secret Key: <button onClick={onSecretsClick} className="text-red-500 hover:underline">{secretKey}</button>
</p>
)}
</div>
);
}
function SystemError() {
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">System Error</h4>
<p className="text-sm text-muted-foreground">
An error occurred while syncing this connection. Please try again later.
</p>
</div>
)
}
function FailedToFetchGerritProjects({ syncStatusMetadata }: { syncStatusMetadata: Prisma.JsonValue}) {
const status = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'status' in syncStatusMetadata
? (syncStatusMetadata.status as number)
: undefined;
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Failed to Fetch Gerrit Projects</h4>
<p className="text-sm text-muted-foreground">
An error occurred while syncing this connection. Please try again later.
</p>
{status && (
<p className="text-sm text-muted-foreground">
Status: <span className="text-red-500">{status}</span>
</p>
)}
</div>
)
}
function UnknownError() {
return (
<div className="space-y-1">
<h4 className="text-sm font-semibold">Unknown Error</h4>
<p className="text-sm text-muted-foreground">
An unknown error occurred while syncing this connection. Please try again later.
</p>
</div>
)
}

View file

@ -1,100 +0,0 @@
'use client';
import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { deleteConnection } from "@/actions";
import { Loader2 } from "lucide-react";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface DeleteConnectionSettingProps {
connectionId: number;
disabled?: boolean;
}
export const DeleteConnectionSetting = ({
connectionId,
disabled,
}: DeleteConnectionSettingProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const domain = useDomain();
const { toast } = useToast();
const router = useRouter();
const captureEvent = useCaptureEvent();
const handleDelete = useCallback(() => {
setIsDialogOpen(false);
setIsLoading(true);
deleteConnection(connectionId, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to delete connection. Reason: ${response.message}`
});
captureEvent('wa_connection_delete_fail', {
error: response.errorCode,
});
} else {
toast({
description: `✅ Connection deleted successfully.`
});
captureEvent('wa_connection_delete_success', {});
router.replace(`/${domain}/connections`);
router.refresh();
}
})
.finally(() => {
setIsLoading(false);
});
}, [connectionId, domain, router, toast, captureEvent]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<h2 className="text-lg font-semibold">Delete Connection</h2>
<p className="text-sm text-muted-foreground mt-2">
Permanently delete this connection from Sourcebot. All linked repositories that are not linked to any other connection will also be deleted.
</p>
<div className="flex flex-row justify-end">
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
className="mt-4"
disabled={isLoading || disabled}
>
{isLoading && <Loader2 className="animate-spin mr-2" />}
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Yes, delete connection</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}

View file

@ -1,100 +0,0 @@
'use client';
import { updateConnectionDisplayName } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(1),
});
interface DisplayNameSettingProps {
connectionId: number;
name: string;
disabled?: boolean;
}
export const DisplayNameSetting = ({
connectionId,
name,
disabled,
}: DisplayNameSettingProps) => {
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name,
},
});
const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true);
updateConnectionDisplayName(connectionId, data.name, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to rename connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection renamed successfully.`
});
router.refresh();
}
}).finally(() => {
setIsLoading(false);
});
}, [connectionId, domain, router, toast]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<Form
{...form}
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-lg font-semibold">Display Name</FormLabel>
{/* @todo : refactor this description into a shared file */}
<FormDescription>This is the {`connection's`} display name within Sourcebot. Examples: <b>public-github</b>, <b>self-hosted-gitlab</b>, <b>gerrit-other</b>, etc.</FormDescription>
<FormControl className="max-w-lg">
<Input
{...field}
spellCheck={false}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={isLoading || disabled}
>
{isLoading && <Loader2 className="animate-spin mr-2" />}
Save
</Button>
</div>
</form>
</Form>
</div>
)
}

View file

@ -1,80 +0,0 @@
'use client';
import { AlertTriangle } from "lucide-react"
import { Prisma, ConnectionSyncStatus } from "@sourcebot/db"
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema"
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { ReloadIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
interface NotFoundWarningProps {
syncStatus: ConnectionSyncStatus
syncStatusMetadata: Prisma.JsonValue
onSecretsClick: () => void
connectionType: string
onRetrySync: () => void
}
export const NotFoundWarning = ({ syncStatus, syncStatusMetadata, onSecretsClick, connectionType, onRetrySync }: NotFoundWarningProps) => {
const captureEvent = useCaptureEvent();
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
if (syncStatus !== ConnectionSyncStatus.SYNCED_WITH_WARNINGS || !parseResult.success || !parseResult.data.notFound) {
return null;
}
const { notFound } = parseResult.data;
if (notFound.users.length === 0 && notFound.orgs.length === 0 && notFound.repos.length === 0) {
return null;
} else {
captureEvent('wa_connection_not_found_warning_displayed', {});
}
return (
<div className="flex flex-col items-start gap-4 border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 px-5 py-5 text-yellow-700 dark:text-yellow-400 rounded-lg">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
<h3 className="font-semibold">Unable to fetch all references</h3>
</div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed">
Some requested references couldn&apos;t be found. Please ensure you&apos;ve provided the information listed below correctly, and that you&apos;ve provided a{" "}
<button onClick={onSecretsClick} className="text-yellow-500 dark:text-yellow-400 font-bold hover:underline">
valid token
</button>{" "}
to access them if they&apos;re private.
</p>
<ul className="w-full space-y-2 text-sm">
{notFound.users.length > 0 && (
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
<span className="font-medium">Users:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.users.join(', ')}</span>
</li>
)}
{notFound.orgs.length > 0 && (
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
<span className="font-medium">{connectionType === "gitlab" ? "Groups" : "Organizations"}:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.orgs.join(', ')}</span>
</li>
)}
{notFound.repos.length > 0 && (
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
<span className="font-medium">{connectionType === "gitlab" ? "Projects" : "Repositories"}:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.repos.join(', ')}</span>
</li>
)}
</ul>
<div className="w-full flex justify-center">
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={onRetrySync}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry Sync
</Button>
</div>
</div>
)
}

View file

@ -1,159 +0,0 @@
'use client';
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { DisplayConnectionError } from "./connectionError"
import { NotFoundWarning } from "./notFoundWarning"
import { useDomain } from "@/hooks/useDomain";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { flagConnectionForSync, getConnectionInfo } from "@/actions";
import { isServiceError, unwrapServiceError } from "@/lib/utils";
import { env } from "@/env.mjs";
import { ConnectionSyncStatus } from "@sourcebot/db";
import { FiLoader } from "react-icons/fi";
import { CircleCheckIcon, AlertTriangle, CircleXIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons";
import { toast } from "@/components/hooks/use-toast";
interface OverviewProps {
connectionId: number;
}
export const Overview = ({ connectionId }: OverviewProps) => {
const captureEvent = useCaptureEvent();
const domain = useDomain();
const router = useRouter();
const { data: connection, isPending, error, refetch } = useQuery({
queryKey: ['connection', domain, connectionId],
queryFn: () => unwrapServiceError(getConnectionInfo(connectionId, domain)),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
const handleSecretsNavigation = useCallback(() => {
captureEvent('wa_connection_secrets_navigation_pressed', {});
router.push(`/${domain}/secrets`);
}, [captureEvent, domain, router]);
const onRetrySync = useCallback(async () => {
const result = await flagConnectionForSync(connectionId, domain);
if (isServiceError(result)) {
toast({
description: `❌ Failed to flag connection for sync.`,
});
captureEvent('wa_connection_retry_sync_fail', {
error: result.errorCode,
});
} else {
toast({
description: "✅ Connection flagged for sync.",
});
captureEvent('wa_connection_retry_sync_success', {});
refetch();
}
}, [connectionId, domain, captureEvent, refetch]);
if (error) {
return <div className="text-destructive">
{`Error loading connection. Reason: ${error.message}`}
</div>
}
if (isPending) {
return (
<div className="grid grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border p-4 bg-background">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
<div className="mt-2 h-4 w-24 bg-muted rounded animate-pulse" />
</div>
))}
</div>
)
}
return (
<div className="mt-4 flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Connection Type</h2>
<p className="mt-2 text-sm">{connection.connectionType}</p>
</div>
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Last Synced At</h2>
<p className="mt-2 text-sm">
{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : "never"}
</p>
</div>
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2>
<p className="mt-2 text-sm">{connection.numLinkedRepos}</p>
</div>
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Status</h2>
<div className="flex items-center gap-2 mt-2">
{connection.syncStatus === "FAILED" ? (
<HoverCard openDelay={50}>
<HoverCardTrigger onMouseEnter={() => captureEvent('wa_connection_failed_status_hover', {})}>
<SyncStatusBadge status={connection.syncStatus} />
</HoverCardTrigger>
<HoverCardContent className="w-80">
<DisplayConnectionError
syncStatusMetadata={connection.syncStatusMetadata}
onSecretsClick={handleSecretsNavigation}
/>
</HoverCardContent>
</HoverCard>
) : (
<SyncStatusBadge status={connection.syncStatus} />
)}
{connection.syncStatus === "FAILED" && (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={onRetrySync}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry Sync
</Button>
)}
</div>
</div>
</div>
<NotFoundWarning
syncStatus={connection.syncStatus}
syncStatusMetadata={connection.syncStatusMetadata}
onSecretsClick={handleSecretsNavigation}
connectionType={connection.connectionType}
onRetrySync={onRetrySync}
/>
</div>
)
}
const SyncStatusBadge = ({ status }: { status: ConnectionSyncStatus }) => {
return (
<Badge
className="select-none px-2 py-1"
variant={status === ConnectionSyncStatus.FAILED ? "destructive" : "outline"}
>
{status === ConnectionSyncStatus.SYNC_NEEDED || status === ConnectionSyncStatus.IN_SYNC_QUEUE ? (
<><FiLoader className="w-4 h-4 mr-2 animate-spin-slow" /> Sync queued</>
) : status === ConnectionSyncStatus.SYNCING ? (
<><FiLoader className="w-4 h-4 mr-2 animate-spin-slow" /> Syncing</>
) : status === ConnectionSyncStatus.SYNCED ? (
<span className="flex flex-row items-center text-green-700 dark:text-green-400"><CircleCheckIcon className="w-4 h-4 mr-2" /> Synced</span>
) : status === ConnectionSyncStatus.SYNCED_WITH_WARNINGS ? (
<span className="flex flex-row items-center text-yellow-700 dark:text-yellow-400"><AlertTriangle className="w-4 h-4 mr-2" /> Synced with warnings</span>
) : status === ConnectionSyncStatus.FAILED ? (
<><CircleXIcon className="w-4 h-4 mr-2" /> Sync failed</>
) : null}
</Badge>
)
}

View file

@ -1,229 +0,0 @@
'use client';
import { useDomain } from "@/hooks/useDomain";
import { useQuery } from "@tanstack/react-query";
import { flagReposForIndex, getConnectionInfo, getRepos } from "@/actions";
import { RepoListItem } from "./repoListItem";
import { isServiceError, unwrapServiceError } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
import { Search, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useCallback, useMemo, useState } from "react";
import { RepoListItemSkeleton } from "./repoListItemSkeleton";
import { env } from "@/env.mjs";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { MultiSelect } from "@/components/ui/multi-select";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useToast } from "@/components/hooks/use-toast";
interface RepoListProps {
connectionId: number;
}
const getPriority = (status: RepoIndexingStatus) => {
switch (status) {
case RepoIndexingStatus.FAILED:
return 0
case RepoIndexingStatus.IN_INDEX_QUEUE:
case RepoIndexingStatus.INDEXING:
return 1
case RepoIndexingStatus.INDEXED:
return 2
default:
return 3
}
}
const convertIndexingStatus = (status: RepoIndexingStatus) => {
switch (status) {
case RepoIndexingStatus.FAILED:
return 'failed';
case RepoIndexingStatus.NEW:
return 'waiting';
case RepoIndexingStatus.IN_INDEX_QUEUE:
case RepoIndexingStatus.INDEXING:
return 'running';
case RepoIndexingStatus.INDEXED:
return 'succeeded';
default:
return 'unknown';
}
}
export const RepoList = ({ connectionId }: RepoListProps) => {
const domain = useDomain();
const router = useRouter();
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const captureEvent = useCaptureEvent();
const [isRetryAllFailedReposLoading, setIsRetryAllFailedReposLoading] = useState(false);
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
queryKey: ['repos', domain, connectionId],
queryFn: async () => {
const repos = await unwrapServiceError(getRepos());
return repos.sort((a, b) => {
const priorityA = getPriority(a.repoIndexingStatus);
const priorityB = getPriority(b.repoIndexingStatus);
// First sort by priority
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// If same priority, sort by indexedAt
return new Date(a.indexedAt ?? new Date()).getTime() - new Date(b.indexedAt ?? new Date()).getTime();
});
},
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
const { data: connection, isPending: isConnectionPending, error: isConnectionError } = useQuery({
queryKey: ['connection', domain, connectionId],
queryFn: () => unwrapServiceError(getConnectionInfo(connectionId, domain)),
})
const failedRepos = useMemo(() => {
return unfilteredRepos?.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.FAILED) ?? [];
}, [unfilteredRepos]);
const onRetryAllFailedRepos = useCallback(() => {
if (failedRepos.length === 0) {
return;
}
setIsRetryAllFailedReposLoading(true);
flagReposForIndex(failedRepos.map((repo) => repo.repoId))
.then((response) => {
if (isServiceError(response)) {
captureEvent('wa_connection_retry_all_failed_repos_fail', {});
toast({
description: `❌ Failed to flag repositories for indexing. Reason: ${response.message}`,
});
} else {
captureEvent('wa_connection_retry_all_failed_repos_success', {});
toast({
description: `${failedRepos.length} repositories flagged for indexing.`,
});
}
})
.then(() => { refetchRepos() })
.finally(() => {
setIsRetryAllFailedReposLoading(false);
});
}, [captureEvent, failedRepos, refetchRepos, toast]);
const filteredRepos = useMemo(() => {
if (isServiceError(unfilteredRepos)) {
return unfilteredRepos;
}
const searchLower = searchQuery.toLowerCase();
return unfilteredRepos?.filter((repo) => {
return repo.repoName.toLowerCase().includes(searchLower);
}).filter((repo) => {
if (selectedStatuses.length === 0) {
return true;
}
return selectedStatuses.includes(convertIndexingStatus(repo.repoIndexingStatus));
});
}, [unfilteredRepos, searchQuery, selectedStatuses]);
if (reposError) {
return <div className="text-destructive">
{`Error loading repositories. Reason: ${reposError.message}`}
</div>
}
return (
<div className="space-y-6">
<div className="flex gap-4 flex-col sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Filter ${isReposPending ? "n" : filteredRepos?.length} ${filteredRepos?.length === 1 ? "repository" : "repositories"} by name`}
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MultiSelect
className="bg-background hover:bg-background w-96"
options={[
{ value: 'waiting', label: 'Waiting' },
{ value: 'running', label: 'Running' },
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'failed', label: 'Failed' },
]}
onValueChange={(value) => setSelectedStatuses(value)}
defaultValue={[]}
placeholder="Filter by status"
maxCount={2}
animation={0}
/>
{failedRepos.length > 0 && (
<Button
variant="outline"
disabled={isRetryAllFailedReposLoading}
onClick={onRetryAllFailedRepos}
>
{isRetryAllFailedReposLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Retry All Failed
</Button>
)}
</div>
<ScrollArea className="mt-4 h-96 pr-4">
{isReposPending ? (
<div className="flex flex-col gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<RepoListItemSkeleton key={i} />
))}
</div>
) : (!filteredRepos || filteredRepos.length === 0) ? (
<div className="flex flex-col items-center justify-center h-96 p-4 border rounded-lg">
<p className="font-medium text-sm">No Repositories Found</p>
<p className="text-sm text-muted-foreground mt-2">
{
searchQuery.length > 0 ? (
<span>No repositories found matching your filters.</span>
) : (!isConnectionError && !isConnectionPending && (connection.syncStatus === ConnectionSyncStatus.IN_SYNC_QUEUE || connection.syncStatus === ConnectionSyncStatus.SYNCING || connection.syncStatus === ConnectionSyncStatus.SYNC_NEEDED)) ? (
<span>Repositories are being synced. Please check back soon.</span>
) : (
<Button
onClick={() => {
router.push(`?tab=settings`)
}}
variant="outline"
>
Configure connection
</Button>
)}
</p>
</div>
) : (
<div className="flex flex-col gap-4">
{filteredRepos?.map((repo) => (
<RepoListItem
key={repo.repoId}
imageUrl={repo.imageUrl}
name={repo.repoName}
indexedAt={repo.indexedAt}
status={repo.repoIndexingStatus}
repoId={repo.repoId}
domain={domain}
/>
))}
</div>
)}
</ScrollArea>
</div>
)
}

View file

@ -1,113 +0,0 @@
'use client';
import { getDisplayTime, getRepoImageSrc } from "@/lib/utils";
import Image from "next/image";
import { StatusIcon } from "../../components/statusIcon";
import { RepoIndexingStatus } from "@sourcebot/db";
import { useMemo } from "react";
import { RetryRepoIndexButton } from "./repoRetryIndexButton";
interface RepoListItemProps {
name: string;
status: RepoIndexingStatus;
imageUrl?: string;
indexedAt?: Date;
repoId: number;
domain: string;
}
export const RepoListItem = ({
imageUrl,
name,
indexedAt,
status,
repoId,
domain,
}: RepoListItemProps) => {
const statusDisplayName = useMemo(() => {
switch (status) {
case RepoIndexingStatus.NEW:
return 'Waiting...';
case RepoIndexingStatus.IN_INDEX_QUEUE:
return 'In index queue...';
case RepoIndexingStatus.INDEXING:
return 'Indexing...';
case RepoIndexingStatus.INDEXED:
return 'Indexed';
case RepoIndexingStatus.FAILED:
return 'Index failed';
case RepoIndexingStatus.IN_GC_QUEUE:
return 'In garbage collection queue...';
case RepoIndexingStatus.GARBAGE_COLLECTING:
return 'Garbage collecting...';
case RepoIndexingStatus.GARBAGE_COLLECTION_FAILED:
return 'Garbage collection failed';
}
}, [status]);
const imageSrc = getRepoImageSrc(imageUrl, repoId, domain);
return (
<div
className="flex flex-row items-center p-4 border rounded-lg bg-background justify-between"
>
<div className="flex flex-row items-center gap-2">
{imageSrc ? (
<Image
src={imageSrc}
alt={name}
width={32}
height={32}
className="object-cover"
/>
) : (
<div className="h-8 w-8 flex items-center justify-center bg-muted text-xs font-medium uppercase text-muted-foreground rounded-md">
{name.charAt(0)}
</div>
)}
<p className="font-medium">{name}</p>
</div>
<div className="flex flex-row items-center gap-4">
{status === RepoIndexingStatus.FAILED && (
<RetryRepoIndexButton repoId={repoId} />
)}
<div className="flex flex-row items-center gap-0">
<StatusIcon
status={convertIndexingStatus(status)}
className="w-4 h-4 mr-1"
/>
<p className="text-sm">
<span>{statusDisplayName}</span>
{
(
status === RepoIndexingStatus.INDEXED ||
status === RepoIndexingStatus.FAILED
) && indexedAt && (
<span>{` ${getDisplayTime(indexedAt)}`}</span>
)
}
</p>
</div>
</div>
</div>
)
}
const convertIndexingStatus = (status: RepoIndexingStatus) => {
switch (status) {
case RepoIndexingStatus.NEW:
return 'waiting';
case RepoIndexingStatus.IN_INDEX_QUEUE:
case RepoIndexingStatus.INDEXING:
return 'running';
case RepoIndexingStatus.IN_GC_QUEUE:
case RepoIndexingStatus.GARBAGE_COLLECTING:
return "garbage-collecting"
case RepoIndexingStatus.INDEXED:
return 'succeeded';
case RepoIndexingStatus.GARBAGE_COLLECTION_FAILED:
case RepoIndexingStatus.FAILED:
return 'failed';
}
}

View file

@ -1,15 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton"
export const RepoListItemSkeleton = () => {
return (
<div className="flex flex-row items-center p-4 border rounded-lg bg-background justify-between">
<div className="flex flex-row items-center gap-2">
<Skeleton className="h-10 w-10 rounded-full animate-pulse" />
<Skeleton className="h-4 w-32 animate-pulse" />
</div>
<div className="flex flex-row items-center gap-2">
<Skeleton className="h-4 w-24 animate-pulse" />
</div>
</div>
)
}

View file

@ -1,43 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagReposForIndex } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetryRepoIndexButtonProps {
repoId: number;
}
export const RetryRepoIndexButton = ({ repoId }: RetryRepoIndexButtonProps) => {
const captureEvent = useCaptureEvent();
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
const result = await flagReposForIndex([repoId]);
if (isServiceError(result)) {
toast({
description: `❌ Failed to flag repository for indexing.`,
});
captureEvent('wa_repo_retry_index_fail', {
error: result.errorCode,
});
} else {
toast({
description: "✅ Repository flagged for indexing.",
});
captureEvent('wa_repo_retry_index_success', {});
}
}}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry Index
</Button>
);
};

View file

@ -1,63 +0,0 @@
import { NotFound } from "@/app/[domain]/components/notFound"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { ConnectionIcon } from "../components/connectionIcon"
import { Header } from "../../components/header"
import { RepoList } from "./components/repoList"
import { getConnectionByDomain } from "@/data/connection"
import { Overview } from "./components/overview"
interface ConnectionManagementPageProps {
params: Promise<{
domain: string
id: string
}>,
}
export default async function ConnectionManagementPage(props: ConnectionManagementPageProps) {
const params = await props.params;
const connection = await getConnectionByDomain(Number(params.id), params.domain);
if (!connection) {
return <NotFound className="flex w-full h-full items-center justify-center" message="Connection not found" />
}
return (
<div>
<Header>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href={`/${params.domain}/connections`}>Connections</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{connection.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="mt-6 flex items-center gap-3">
<ConnectionIcon type={connection.connectionType} />
<h1 className="text-3xl font-semibold">{connection.name}</h1>
</div>
</Header>
<div className="space-y-8">
<div>
<h2 className="text-lg font-medium mb-4">Overview</h2>
<Overview connectionId={connection.id} />
</div>
<div>
<h2 className="text-lg font-medium mb-4">Linked Repositories</h2>
<RepoList connectionId={connection.id} />
</div>
</div>
</div>
)
}

View file

@ -1,38 +0,0 @@
'use client';
import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils";
import { useMemo } from "react";
import Image from "next/image";
import placeholderLogo from "@/public/placeholder_avatar.png";
interface ConnectionIconProps {
type: string;
className?: string;
}
export const ConnectionIcon = ({
type,
className,
}: ConnectionIconProps) => {
const Icon = useMemo(() => {
const iconInfo = getCodeHostIcon(type as CodeHostType);
if (iconInfo) {
return (
<Image
src={iconInfo.src}
className={cn(cn("rounded-full w-8 h-8", iconInfo.className), className)}
alt={`${type} logo`}
/>
)
}
return <Image
src={placeholderLogo}
alt={''}
className={cn("rounded-full w-8 h-8", className)}
/>
}, [className, type]);
return Icon;
}

View file

@ -1,120 +0,0 @@
import { getDisplayTime } from "@/lib/utils";
import { useMemo } from "react";
import { ConnectionIcon } from "../connectionIcon";
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
import { StatusIcon } from "../statusIcon";
import { ConnectionListItemErrorIndicator } from "./connectionListItemErrorIndicator";
import { ConnectionListItemWarningIndicator } from "./connectionListItemWarningIndicator";
import { ConnectionListItemManageButton } from "./connectionListItemManageButton";
const convertSyncStatus = (status: ConnectionSyncStatus) => {
switch (status) {
case ConnectionSyncStatus.SYNC_NEEDED:
return 'waiting';
case ConnectionSyncStatus.IN_SYNC_QUEUE:
case ConnectionSyncStatus.SYNCING:
return 'running';
case ConnectionSyncStatus.SYNCED:
return 'succeeded';
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
return 'succeeded-with-warnings';
case ConnectionSyncStatus.FAILED:
return 'failed';
}
}
interface ConnectionListItemProps {
id: string;
name: string;
type: string;
status: ConnectionSyncStatus;
syncStatusMetadata: Prisma.JsonValue;
editedAt: Date;
syncedAt?: Date;
failedRepos?: { repoId: number, repoName: string }[];
disabled: boolean;
}
export const ConnectionListItem = ({
id,
name,
type,
status,
syncStatusMetadata,
editedAt,
syncedAt,
failedRepos,
disabled,
}: ConnectionListItemProps) => {
const statusDisplayName = useMemo(() => {
switch (status) {
case ConnectionSyncStatus.SYNC_NEEDED:
return 'Waiting...';
case ConnectionSyncStatus.IN_SYNC_QUEUE:
case ConnectionSyncStatus.SYNCING:
return 'Syncing...';
case ConnectionSyncStatus.SYNCED:
return 'Synced';
case ConnectionSyncStatus.FAILED:
return 'Sync failed';
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
return null;
}
}, [status]);
const { notFoundData, displayNotFoundWarning } = useMemo(() => {
if (!syncStatusMetadata || typeof syncStatusMetadata !== 'object' || !('notFound' in syncStatusMetadata)) {
return { notFoundData: null, displayNotFoundWarning: false };
}
const notFoundData = syncStatusMetadata.notFound as {
users: string[],
orgs: string[],
repos: string[],
}
return { notFoundData, displayNotFoundWarning: notFoundData.users.length > 0 || notFoundData.orgs.length > 0 || notFoundData.repos.length > 0 };
}, [syncStatusMetadata]);
return (
<div
className="flex flex-row justify-between items-center border p-4 rounded-lg bg-background"
>
<div className="flex flex-row items-center gap-3">
<ConnectionIcon
type={type}
className="w-8 h-8"
/>
<div className="flex flex-col">
<p className="font-medium">{name}</p>
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
</div>
<ConnectionListItemErrorIndicator failedRepos={failedRepos} connectionId={id} />
<ConnectionListItemWarningIndicator
notFoundData={notFoundData}
connectionId={id}
type={type}
displayWarning={displayNotFoundWarning}
/>
</div>
<div className="flex flex-row items-center">
<StatusIcon
status={convertSyncStatus(status)}
className="w-4 h-4 mr-1"
/>
<p className="text-sm">
<span>{statusDisplayName}</span>
{
(
status === ConnectionSyncStatus.SYNCED ||
status === ConnectionSyncStatus.FAILED
) && syncedAt && (
<span>{` ${getDisplayTime(syncedAt)}`}</span>
)
}
</p>
<ConnectionListItemManageButton id={id} disabled={disabled} />
</div>
</div>
)
}

View file

@ -1,62 +0,0 @@
'use client'
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { CircleX } from "lucide-react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface ConnectionListItemErrorIndicatorProps {
failedRepos: { repoId: number; repoName: string; }[] | undefined;
connectionId: string;
}
export const ConnectionListItemErrorIndicator = ({
failedRepos,
connectionId
}: ConnectionListItemErrorIndicatorProps) => {
const captureEvent = useCaptureEvent()
if (!failedRepos || failedRepos.length === 0) return null;
return (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<CircleX
className="h-5 w-5 text-red-700 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
onClick={() => {
captureEvent('wa_connection_list_item_error_pressed', {})
window.location.href = `connections/${connectionId}`
}}
onMouseEnter={() => captureEvent('wa_connection_list_item_error_hover', {})}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-red-200 dark:border-red-800">
<CircleX className="h-4 w-4 text-red-700 dark:text-red-400" />
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400">Failed to Index Repositories</h3>
</div>
<div className="text-sm text-red-600/90 dark:text-red-300/90 space-y-3">
<p>
{failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load.
</p>
<div className="space-y-2 text-sm bg-red-50 dark:bg-red-900/20 rounded-md p-3 border border-red-200/50 dark:border-red-800/50">
<div className="flex flex-col gap-1.5">
{failedRepos.slice(0, 10).map(repo => (
<span key={repo.repoId} className="text-red-700 dark:text-red-300">{repo.repoName}</span>
))}
{failedRepos.length > 10 && (
<span className="text-red-600/75 dark:text-red-400/75 text-xs pt-1">
And {failedRepos.length - 10} more...
</span>
)}
</div>
</div>
<p className="text-xs">
Navigate to the connection for more details and to retry indexing.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
};

View file

@ -1,37 +0,0 @@
'use client'
import { Button } from "@/components/ui/button";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
interface ConnectionListItemManageButtonProps {
id: string;
disabled: boolean;
}
export const ConnectionListItemManageButton = ({
id,
disabled,
}: ConnectionListItemManageButtonProps) => {
const captureEvent = useCaptureEvent()
const router = useRouter();
const domain = useDomain();
return (
<Button
variant="outline"
size={"sm"}
className="ml-4"
disabled={disabled}
onClick={() => {
if (!disabled) {
captureEvent('wa_connection_list_item_manage_pressed', {})
router.push(`/${domain}/connections/${id}`)
}
}}
>
Manage
</Button>
);
};

View file

@ -1,78 +0,0 @@
'use client'
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { AlertTriangle } from "lucide-react";
import { NotFoundData } from "@/lib/syncStatusMetadataSchema";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface ConnectionListItemWarningIndicatorProps {
notFoundData: NotFoundData | null;
connectionId: string;
type: string;
displayWarning: boolean;
}
export const ConnectionListItemWarningIndicator = ({
notFoundData,
connectionId,
type,
displayWarning
}: ConnectionListItemWarningIndicatorProps) => {
const captureEvent = useCaptureEvent()
if (!notFoundData || !displayWarning) return null;
return (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<AlertTriangle
className="h-5 w-5 text-yellow-700 dark:text-yellow-400 cursor-help hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
onClick={() => {
captureEvent('wa_connection_list_item_warning_pressed', {})
window.location.href = `connections/${connectionId}`
}}
onMouseEnter={() => captureEvent('wa_connection_list_item_warning_hover', {})}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-800">
<AlertTriangle className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
<h3 className="text-sm font-semibold text-yellow-700 dark:text-yellow-400">Unable to fetch all references</h3>
</div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90">
Some requested references couldn&apos;t be found. Verify the details below and ensure your connection is using a {" "}
<button
onClick={() => window.location.href = `secrets`}
className="font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
>
valid access token
</button>{" "}
that has access to any private references.
</p>
<ul className="space-y-2 text-sm bg-yellow-50 dark:bg-yellow-900/20 rounded-md p-3 border border-yellow-200/50 dark:border-yellow-800/50">
{notFoundData.users.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">Users:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.users.join(', ')}</span>
</li>
)}
{notFoundData.orgs.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Groups" : "Organizations"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.orgs.join(', ')}</span>
</li>
)}
{notFoundData.repos.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Projects" : "Repositories"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.repos.join(', ')}</span>
</li>
)}
</ul>
</div>
</HoverCardContent>
</HoverCard>
);
};

View file

@ -1,144 +0,0 @@
"use client";
import { useDomain } from "@/hooks/useDomain";
import { ConnectionListItem } from "./connectionListItem";
import { cn, unwrapServiceError } from "@/lib/utils";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import { getConnections } from "@/actions";
import { Skeleton } from "@/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { env } from "@/env.mjs";
import { RepoIndexingStatus, ConnectionSyncStatus } from "@sourcebot/db";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { MultiSelect } from "@/components/ui/multi-select";
interface ConnectionListProps {
className?: string;
isDisabled: boolean;
}
const convertSyncStatus = (status: ConnectionSyncStatus) => {
switch (status) {
case ConnectionSyncStatus.SYNC_NEEDED:
return 'waiting';
case ConnectionSyncStatus.SYNCING:
return 'running';
case ConnectionSyncStatus.SYNCED:
return 'succeeded';
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
return 'synced-with-warnings';
case ConnectionSyncStatus.FAILED:
return 'failed';
default:
return 'unknown';
}
}
export const ConnectionList = ({
className,
isDisabled,
}: ConnectionListProps) => {
const domain = useDomain();
const [searchQuery, setSearchQuery] = useState("");
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const { data: unfilteredConnections, isPending, error } = useQuery({
queryKey: ['connections', domain],
queryFn: () => unwrapServiceError(getConnections(domain)),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
const connections = useMemo(() => {
return unfilteredConnections
?.filter((connection) => connection.name.toLowerCase().includes(searchQuery.toLowerCase()))
.filter((connection) => {
if (selectedStatuses.length === 0) {
return true;
}
return selectedStatuses.includes(convertSyncStatus(connection.syncStatus));
})
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) ?? [];
}, [unfilteredConnections, searchQuery, selectedStatuses]);
if (error) {
return <div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
<p>Error loading connections: {error.message}</p>
</div>
}
return (
<div className={cn("flex flex-col gap-4", className)}>
<div className="flex gap-4 flex-col sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Filter ${isPending ? "n" : connections.length} ${connections.length === 1 ? "connection" : "connections"} by name`}
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MultiSelect
className="bg-background hover:bg-background w-56"
options={[
{ value: 'waiting', label: 'Waiting' },
{ value: 'running', label: 'Syncing' },
{ value: 'succeeded', label: 'Synced' },
{ value: 'synced-with-warnings', label: 'Warnings' },
{ value: 'failed', label: 'Failed' },
]}
onValueChange={(value) => setSelectedStatuses(value)}
defaultValue={[]}
placeholder="Filter by status"
maxCount={2}
animation={0}
/>
</div>
{isPending ? (
// Skeleton for loading state
<div className="flex flex-col gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border rounded-md p-4">
<Skeleton className="w-8 h-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-3 w-1/3" />
</div>
<Skeleton className="w-24 h-8" />
</div>
))}
</div>
) : connections.length > 0 ? (
connections
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
.map((connection) => (
<ConnectionListItem
key={connection.id}
id={connection.id.toString()}
name={connection.name}
type={connection.connectionType}
status={connection.syncStatus}
syncStatusMetadata={connection.syncStatusMetadata}
editedAt={connection.updatedAt}
syncedAt={connection.syncedAt ?? undefined}
failedRepos={connection.linkedRepos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
repoId: repo.id,
repoName: repo.name,
}))}
disabled={isDisabled}
/>
))
) : (
<div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
<InfoCircledIcon className="w-7 h-7" />
<h2 className="mt-2 font-medium">No connections</h2>
</div>
)}
</div>
);
}

View file

@ -1,29 +0,0 @@
import { cn } from "@/lib/utils";
import { CircleCheckIcon, CircleXIcon } from "lucide-react";
import { useMemo } from "react";
import { FiLoader } from "react-icons/fi";
export type Status = 'waiting' | 'running' | 'succeeded' | 'succeeded-with-warnings' | 'garbage-collecting' | 'failed';
export const StatusIcon = ({
status,
className,
}: { status: Status, className?: string }) => {
const Icon = useMemo(() => {
switch (status) {
case 'waiting':
case 'garbage-collecting':
case 'running':
return <FiLoader className={cn('animate-spin-slow', className)} />;
case 'succeeded':
return <CircleCheckIcon className={cn('text-green-600', className)} />;
case 'failed':
return <CircleXIcon className={cn('text-destructive', className)} />;
case 'succeeded-with-warnings':
default:
return null;
}
}, [className, status]);
return Icon;
}

View file

@ -1,36 +0,0 @@
import { auth } from "@/auth";
import { NavigationMenu } from "../components/navigationMenu";
import { redirect } from "next/navigation";
interface LayoutProps {
children: React.ReactNode;
params: Promise<{ domain: string }>;
}
export default async function Layout(
props: LayoutProps
) {
const params = await props.params;
const {
domain
} = params;
const {
children
} = props;
const session = await auth();
if (!session) {
return redirect(`/${domain}`);
}
return (
<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 p-6">{children}</div>
</main>
</div>
)
}

View file

@ -1,35 +0,0 @@
import { ConnectionList } from "./components/connectionList";
import { Header } from "../components/header";
import { getConnections, getOrgMembership } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { notFound, ServiceErrorException } from "@/lib/serviceError";
import { OrgRole } from "@sourcebot/db";
export default async function ConnectionsPage(props: { params: Promise<{ domain: string }> }) {
const params = await props.params;
const {
domain
} = params;
const connections = await getConnections(domain);
if (isServiceError(connections)) {
throw new ServiceErrorException(connections);
}
const membership = await getOrgMembership(domain);
if (isServiceError(membership)) {
throw new ServiceErrorException(notFound());
}
return (
<div>
<Header>
<h1 className="text-3xl">Connections</h1>
</Header>
<ConnectionList
isDisabled={membership.role !== OrgRole.OWNER}
/>
</div>
);
}

View file

@ -1,575 +0,0 @@
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
import { QuickAction } from "../components/configEditor";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
import { CodeSnippet } from "@/app/components/codeSnippet";
export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
repos: [
...(previous.repos ?? []),
"<owner>/<repo name>"
]
}),
name: "Add a single repo",
selectionText: "<owner>/<repo name>",
description: (
<div className="flex flex-col">
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"sourcebot/sourcebot",
"vercel/next.js",
"torvalds/linux"
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
orgs: [
...(previous.orgs ?? []),
"<organization name>"
]
}),
name: "Add an organization",
selectionText: "<organization name>",
description: (
<div className="flex flex-col">
<span>Add an organization to sync with. All repositories in the organization visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
"commaai",
"sourcebot",
"vercel"
].map((org) => (
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
users: [
...(previous.users ?? []),
"<username>"
]
}),
name: "Add a user",
selectionText: "<username>",
description: (
<div className="flex flex-col">
<span>Add a user to sync with. All repositories that the user owns visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
"jane-doe",
"torvalds",
"octocat"
].map((org) => (
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
url: previous.url ?? "https://github.example.com",
}),
name: "Set url to GitHub instance",
selectionText: "https://github.example.com",
description: <span>Set a custom GitHub host. Defaults to <CodeSnippet>https://github.com</CodeSnippet>.</span>
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
repos: [
...(previous.exclude?.repos ?? []),
"<glob pattern>"
]
}
}),
name: "Exclude by repo name",
selectionText: "<glob pattern>",
description: (
<div className="flex flex-col">
<span>Exclude repositories from syncing by name. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"my-org/docs*",
"my-org/test*"
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
topics: [
...(previous.exclude?.topics ?? []),
"<topic>"
]
}
}),
name: "Exclude by topic",
selectionText: "<topic>",
description: (
<div className="flex flex-col">
<span>Exclude topics from syncing. Only repos that do not match any of the provided topics will be synced. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"docs",
"ci"
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
topics: [
...(previous.topics ?? []),
"<topic>"
]
}),
name: "Include by topic",
selectionText: "<topic>",
description: (
<div className="flex flex-col">
<span>Include repositories by topic. Only repos that match at least one of the provided topics will be synced. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"docs",
"ci"
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
archived: true,
}
}),
name: "Exclude archived repos",
description: <span>Exclude archived repositories from syncing.</span>
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
forks: true,
}
}),
name: "Exclude forked repos",
description: <span>Exclude forked repositories from syncing.</span>
}
];
export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
projects: [
...previous.projects ?? [],
"<project name>"
]
}),
name: "Add a project",
selectionText: "<project name>",
description: (
<div className="flex flex-col">
<span>Add a individual project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"gitlab-org/gitlab",
"corp/team-project",
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
users: [
...previous.users ?? [],
"<username>"
]
}),
name: "Add a user",
selectionText: "<username>",
description: (
<div className="flex flex-col">
<span>Add a user to sync with. All projects that the user owns visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
"jane-doe",
"torvalds"
].map((org) => (
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
groups: [
...previous.groups ?? [],
"<group name>"
]
}),
name: "Add a group",
selectionText: "<group name>",
description: (
<div className="flex flex-col">
<span>Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"my-group",
"path/to/subgroup"
].map((org) => (
<CodeSnippet key={org}>{org}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
url: previous.url ?? "https://gitlab.example.com",
}),
name: "Set url to GitLab instance",
selectionText: "https://gitlab.example.com",
description: <span>Set a custom GitLab host. Defaults to <CodeSnippet>https://gitlab.com</CodeSnippet>.</span>
},
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
all: true,
}),
name: "Sync all projects",
description: <span>Sync all projects visible to the provided <CodeSnippet>token</CodeSnippet> (if any). Only available when using a self-hosted GitLab instance.</span>
},
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
projects: [
...(previous.exclude?.projects ?? []),
"<glob pattern>"
]
}
}),
name: "Exclude a project",
selectionText: "<glob pattern>",
description: (
<div className="flex flex-col">
<span>List of projects to exclude from syncing. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"docs/**",
"**/tests/**",
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
}
]
export const giteaQuickActions: QuickAction<GiteaConnectionConfig>[] = [
{
fn: (previous: GiteaConnectionConfig) => ({
...previous,
orgs: [
...(previous.orgs ?? []),
"<organization name>"
]
}),
name: "Add an organization",
selectionText: "<organization name>",
},
{
fn: (previous: GiteaConnectionConfig) => ({
...previous,
repos: [
...(previous.repos ?? []),
"<owner>/<repo name>"
]
}),
name: "Add a repo",
selectionText: "<owner>/<repo name>",
},
{
fn: (previous: GiteaConnectionConfig) => ({
...previous,
url: previous.url ?? "https://gitea.example.com",
}),
name: "Set url to Gitea instance",
selectionText: "https://gitea.example.com",
}
]
export const gerritQuickActions: QuickAction<GerritConnectionConfig>[] = [
{
fn: (previous: GerritConnectionConfig) => ({
...previous,
projects: [
...(previous.projects ?? []),
""
]
}),
name: "Add a project",
},
{
fn: (previous: GerritConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
projects: [
...(previous.exclude?.projects ?? []),
""
]
}
}),
name: "Exclude a project",
}
]
export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[] = [
{
// add user
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
user: previous.user ?? "username"
}),
name: "Add username",
selectionText: "username",
description: (
<div className="flex flex-col">
<span>Username to use for authentication. This is only required if you&apos;re using an App Password (stored in <CodeSnippet>token</CodeSnippet>) for authentication.</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
workspaces: [
...(previous.workspaces ?? []),
"myWorkspace"
]
}),
name: "Add a workspace",
selectionText: "myWorkspace",
description: (
<div className="flex flex-col">
<span>Add a workspace to sync with. Ensure the workspace is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
repos: [
...(previous.repos ?? []),
"myWorkspace/myRepo"
]
}),
name: "Add a repo",
selectionText: "myWorkspace/myRepo",
description: (
<div className="flex flex-col">
<span>Add an individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
projects: [
...(previous.projects ?? []),
"myProject"
]
}),
name: "Add a project",
selectionText: "myProject",
description: (
<div className="flex flex-col">
<span>Add a project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
repos: [...(previous.exclude?.repos ?? []), "myWorkspace/myExcludedRepo"]
}
}),
name: "Exclude a repo",
selectionText: "myWorkspace/myExcludedRepo",
description: (
<div className="flex flex-col">
<span>Exclude a repository from syncing. Glob patterns are supported.</span>
</div>
)
},
// exclude forked
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
forks: true
}
}),
name: "Exclude forked repos",
description: <span>Exclude forked repositories from syncing.</span>
}
]
export const bitbucketDataCenterQuickActions: QuickAction<BitbucketConnectionConfig>[] = [
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
url: previous.url ?? "https://bitbucket.example.com",
}),
name: "Set url to Bitbucket DC instance",
selectionText: "https://bitbucket.example.com",
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
repos: [
...(previous.repos ?? []),
"myProject/myRepo"
]
}),
name: "Add a repo",
selectionText: "myProject/myRepo",
description: (
<div className="flex flex-col">
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"PROJ/repo-name",
"MYPROJ/api"
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
projects: [
...(previous.projects ?? []),
"myProject"
]
}),
name: "Add a project",
selectionText: "myProject",
description: (
<div className="flex flex-col">
<span>Add a project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
repos: [...(previous.exclude?.repos ?? []), "myProject/myExcludedRepo"]
}
}),
name: "Exclude a repo",
selectionText: "myProject/myExcludedRepo",
description: (
<div className="flex flex-col">
<span>Exclude a repository from syncing. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"myProject/myExcludedRepo",
"myProject2/*"
].map((repo) => (
<CodeSnippet key={repo}>{repo}</CodeSnippet>
))}
</div>
</div>
)
},
// exclude archived
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
archived: true
}
}),
name: "Exclude archived repos",
},
// exclude forked
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
forks: true
}
}),
name: "Exclude forked repos",
}
]

View file

@ -1,40 +0,0 @@
import Ajv, { Schema } from "ajv";
import { z } from "zod";
export const createZodConnectionConfigValidator = <T>(jsonSchema: Schema, additionalConfigValidation?: (config: T) => { message: string, isValid: boolean }) => {
const ajv = new Ajv({
validateFormats: false,
});
const validate = ajv.compile(jsonSchema);
return z
.string()
.superRefine((data, ctx) => {
const addIssue = (message: string) => {
return ctx.addIssue({
code: "custom",
message: `Schema validation error: ${message}`
});
}
let parsed;
try {
parsed = JSON.parse(data);
} catch {
addIssue("Invalid JSON");
return;
}
const valid = validate(parsed);
if (!valid) {
addIssue(ajv.errorsText(validate.errors));
}
if (additionalConfigValidation) {
const result = additionalConfigValidation(parsed as T);
if (!result.isValid) {
addIssue(result.message);
}
}
});
}