mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
delete connections components
This commit is contained in:
parent
88705f5e7e
commit
cfb359351d
21 changed files with 0 additions and 2206 deletions
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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't be found. Please ensure you've provided the information listed below correctly, and that you'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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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",
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue