Connections UX pass + query optimizations (#212)

This commit is contained in:
Brendan Kellam 2025-02-26 15:46:37 -08:00 committed by GitHub
parent b77f55fa20
commit 50b94b2c46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1430 additions and 948 deletions

View file

@ -1,6 +1,6 @@
import { Connection, ConnectionSyncStatus, PrismaClient, Prisma, Repo } from "@sourcebot/db";
import { Connection, ConnectionSyncStatus, PrismaClient, Prisma } from "@sourcebot/db";
import { Job, Queue, Worker } from 'bullmq';
import { Settings, WithRequired } from "./types.js";
import { Settings } from "./types.js";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { createLogger } from "./logger.js";
import os from 'os';
@ -24,7 +24,7 @@ type JobPayload = {
};
type JobResult = {
repoCount: number
repoCount: number,
}
export class ConnectionManager implements IConnectionManager {
@ -82,7 +82,7 @@ export class ConnectionManager implements IConnectionManager {
}, this.settings.resyncConnectionPollingIntervalMs);
}
private async runSyncJob(job: Job<JobPayload>) {
private async runSyncJob(job: Job<JobPayload>): Promise<JobResult> {
const { config, orgId } = job.data;
// @note: We aren't actually doing anything with this atm.
const abortController = new AbortController();
@ -105,6 +105,7 @@ export class ConnectionManager implements IConnectionManager {
id: job.data.connectionId,
},
data: {
syncStatus: ConnectionSyncStatus.SYNCING,
syncStatusMetadata: {}
}
})
@ -233,12 +234,25 @@ export class ConnectionManager implements IConnectionManager {
this.logger.info(`Connection sync job ${job.id} completed`);
const { connectionId } = job.data;
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
where: { id: connectionId },
select: { syncStatusMetadata: true }
}))?.syncStatusMetadata as Record<string, unknown> ?? {};
const { notFound } = syncStatusMetadata as { notFound: {
users: string[],
orgs: string[],
repos: string[],
}};
await this.db.connection.update({
where: {
id: connectionId,
},
data: {
syncStatus: ConnectionSyncStatus.SYNCED,
syncStatus:
notFound.users.length > 0 ||
notFound.orgs.length > 0 ||
notFound.repos.length > 0 ? ConnectionSyncStatus.SYNCED_WITH_WARNINGS : ConnectionSyncStatus.SYNCED,
syncedAt: new Date()
}
})

View file

@ -82,7 +82,7 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
const tagGlobs = config.revisions.tags;
allRepos = await Promise.all(
allRepos.map(async (allRepos) => {
const [owner, name] = allRepos.name!.split('/');
const [owner, name] = allRepos.full_name!.split('/');
let tags = (await fetchWithRetry(
() => getTagsForRepo(owner, name, api),
`tags for ${owner}/${name}`,

View file

@ -12,7 +12,6 @@ export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => {
const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
const token = tokenResult?.token ?? FALLBACK_GITLAB_TOKEN;
const secretKey = tokenResult?.secretKey;
const api = new Gitlab({
...(token ? {

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ConnectionSyncStatus" ADD VALUE 'SYNCED_WITH_WARNINGS';

View file

@ -26,6 +26,7 @@ enum ConnectionSyncStatus {
IN_SYNC_QUEUE
SYNCING
SYNCED
SYNCED_WITH_WARNINGS
FAILED
}

View file

@ -26,11 +26,7 @@ const nextConfig = {
remotePatterns: [
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
{
protocol: 'https',
hostname: 'gitlab.com',
hostname: '**',
},
]
}

View file

@ -13,8 +13,8 @@ import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto"
import { getConnection, getLinkedRepos } from "./data/connection";
import { ConnectionSyncStatus, Prisma, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { getConnection } from "./data/connection";
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { headers } from "next/headers"
import { getStripe } from "@/lib/stripe"
import { getUser } from "@/data/user";
@ -251,23 +251,23 @@ export const deleteSecret = async (key: string, domain: string): Promise<{ succe
}));
export const getConnections = async (domain: string): Promise<
{
id: number,
name: string,
syncStatus: ConnectionSyncStatus,
syncStatusMetadata: Prisma.JsonValue,
connectionType: string,
updatedAt: Date,
syncedAt?: Date
}[] | ServiceError
> =>
export const getConnections = async (domain: string, filter: { status?: ConnectionSyncStatus[] } = {}) =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const connections = await prisma.connection.findMany({
where: {
orgId,
...(filter.status ? {
syncStatus: { in: filter.status }
} : {}),
},
include: {
repos: {
include: {
repo: true,
}
}
}
});
return connections.map((connection) => ({
@ -278,45 +278,78 @@ export const getConnections = async (domain: string): Promise<
connectionType: connection.connectionType,
updatedAt: connection.updatedAt,
syncedAt: connection.syncedAt ?? undefined,
linkedRepos: connection.repos.map(({ repo }) => ({
id: repo.id,
name: repo.name,
repoIndexingStatus: repo.repoIndexingStatus,
})),
}));
})
);
export const getConnectionFailedRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
export const getConnectionInfo = async (connectionId: number, domain: string) =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
const connection = await prisma.connection.findUnique({
where: {
id: connectionId,
orgId,
},
include: {
repos: true,
}
});
if (!connection) {
return notFound();
}
const linkedRepos = await getLinkedRepos(connectionId, orgId);
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
repoId: repo.repo.id,
repoName: repo.repo.name,
}));
return {
id: connection.id,
name: connection.name,
syncStatus: connection.syncStatus,
syncStatusMetadata: connection.syncStatusMetadata,
connectionType: connection.connectionType,
updatedAt: connection.updatedAt,
syncedAt: connection.syncedAt ?? undefined,
numLinkedRepos: connection.repos.length,
}
})
);
)
export const getConnectionInProgressRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
const repos = await prisma.repo.findMany({
where: {
orgId,
...(filter.status ? {
repoIndexingStatus: { in: filter.status }
} : {}),
...(filter.connectionId ? {
connections: {
some: {
connectionId: filter.connectionId
}
}
} : {}),
},
include: {
connections: true,
}
});
const linkedRepos = await getLinkedRepos(connectionId, orgId);
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repo.repoIndexingStatus === RepoIndexingStatus.INDEXING).map((repo) => ({
repoId: repo.repo.id,
repoName: repo.repo.name,
return repos.map((repo) => ({
repoId: repo.id,
repoName: repo.name,
linkedConnections: repo.connections.map((connection) => connection.connectionId),
imageUrl: repo.imageUrl ?? undefined,
indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus,
}));
})
);
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
@ -339,41 +372,6 @@ export const createConnection = async (name: string, type: string, connectionCon
}
}));
export const getConnectionInfoAction = async (connectionId: number, domain: string): Promise<{ connection: Connection, linkedRepos: Repo[] } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
const linkedRepos = await getLinkedRepos(connectionId, orgId);
return {
connection,
linkedRepos: linkedRepos.map((repo) => repo.repo),
}
})
);
export const getOrgFromDomainAction = async (domain: string): Promise<Org | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
return notFound();
}
return org;
})
);
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
@ -458,22 +456,13 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
}
}));
export const flagRepoForIndex = async (repoId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
export const flagReposForIndex = async (repoIds: number[], domain: string) =>
withAuth((session) =>
withOrgMembership(session, domain, async () => {
const repo = await prisma.repo.findUnique({
withOrgMembership(session, domain, async ({ orgId }) => {
await prisma.repo.updateMany({
where: {
id: repoId,
},
});
if (!repo) {
return notFound();
}
await prisma.repo.update({
where: {
id: repoId,
id: { in: repoIds },
orgId,
},
data: {
repoIndexingStatus: RepoIndexingStatus.NEW,
@ -486,8 +475,6 @@ export const flagRepoForIndex = async (repoId: number, domain: string): Promise<
})
);
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
@ -654,7 +641,6 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async (session) => {
const invite = await prisma.invite.findUnique({
@ -828,97 +814,6 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
}, /* minRequiredRole = */ OrgRole.OWNER)
);
const parseConnectionConfig = (connectionType: string, config: string) => {
let parsedConfig: ConnectionConfig;
try {
parsedConfig = JSON.parse(config);
} catch (_e) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "config must be a valid JSON object."
} satisfies ServiceError;
}
const schema = (() => {
switch (connectionType) {
case "github":
return githubSchema;
case "gitlab":
return gitlabSchema;
case 'gitea':
return giteaSchema;
case 'gerrit':
return gerritSchema;
}
})();
if (!schema) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "invalid connection type",
} satisfies ServiceError;
}
const { numRepos, hasToken } = (() => {
switch (connectionType) {
case "github": {
const githubConfig = parsedConfig as GithubConnectionConfig;
return {
numRepos: githubConfig.repos?.length,
hasToken: !!githubConfig.token,
}
}
case "gitlab": {
const gitlabConfig = parsedConfig as GitlabConnectionConfig;
return {
numRepos: gitlabConfig.projects?.length,
hasToken: !!gitlabConfig.token,
}
}
case "gitea": {
const giteaConfig = parsedConfig as GiteaConnectionConfig;
return {
numRepos: giteaConfig.repos?.length,
hasToken: !!giteaConfig.token,
}
}
case "gerrit": {
const gerritConfig = parsedConfig as GerritConnectionConfig;
return {
numRepos: gerritConfig.projects?.length,
hasToken: true, // gerrit doesn't use a token atm
}
}
default:
return {
numRepos: undefined,
hasToken: true
}
}
})();
if (!hasToken && numRepos && numRepos > CONFIG_MAX_REPOS_NO_TOKEN) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `You must provide a token to sync more than ${CONFIG_MAX_REPOS_NO_TOKEN} repositories.`,
} satisfies ServiceError;
}
const isValidConfig = ajv.validate(schema, parsedConfig);
if (!isValidConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
} satisfies ServiceError;
}
return parsedConfig;
}
export const createOnboardingStripeCheckoutSession = async (domain: string) =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
@ -1071,8 +966,6 @@ export const createStripeCheckoutSession = async (domain: string) =>
})
)
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
@ -1104,32 +997,6 @@ export const fetchSubscription = (domain: string): Promise<Stripe.Subscription |
})
);
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
return notFound();
}
if (!org.stripeCustomerId) {
return null;
}
const stripe = getStripe();
const subscriptions = await stripe.subscriptions.list({
customer: org.stripeCustomerId
});
if (subscriptions.data.length === 0) {
return orgInvalidSubscription();
}
return subscriptions.data[0];
}
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
@ -1176,16 +1043,6 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const checkIfUserHasOrg = async (userId: string): Promise<boolean | ServiceError> => {
const orgs = await prisma.userToOrg.findMany({
where: {
userId,
},
});
return orgs.length > 0;
}
export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> =>
withAuth(async () => {
const org = await prisma.org.findFirst({
@ -1357,7 +1214,6 @@ export const getOrgMembers = async (domain: string) =>
})
);
export const getOrgInvites = async (domain: string) =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
@ -1374,3 +1230,123 @@ export const getOrgInvites = async (domain: string) =>
}));
})
);
////// Helpers ///////
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
return notFound();
}
if (!org.stripeCustomerId) {
return null;
}
const stripe = getStripe();
const subscriptions = await stripe.subscriptions.list({
customer: org.stripeCustomerId
});
if (subscriptions.data.length === 0) {
return orgInvalidSubscription();
}
return subscriptions.data[0];
}
const parseConnectionConfig = (connectionType: string, config: string) => {
let parsedConfig: ConnectionConfig;
try {
parsedConfig = JSON.parse(config);
} catch (_e) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "config must be a valid JSON object."
} satisfies ServiceError;
}
const schema = (() => {
switch (connectionType) {
case "github":
return githubSchema;
case "gitlab":
return gitlabSchema;
case 'gitea':
return giteaSchema;
case 'gerrit':
return gerritSchema;
}
})();
if (!schema) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "invalid connection type",
} satisfies ServiceError;
}
const { numRepos, hasToken } = (() => {
switch (connectionType) {
case "github": {
const githubConfig = parsedConfig as GithubConnectionConfig;
return {
numRepos: githubConfig.repos?.length,
hasToken: !!githubConfig.token,
}
}
case "gitlab": {
const gitlabConfig = parsedConfig as GitlabConnectionConfig;
return {
numRepos: gitlabConfig.projects?.length,
hasToken: !!gitlabConfig.token,
}
}
case "gitea": {
const giteaConfig = parsedConfig as GiteaConnectionConfig;
return {
numRepos: giteaConfig.repos?.length,
hasToken: !!giteaConfig.token,
}
}
case "gerrit": {
const gerritConfig = parsedConfig as GerritConnectionConfig;
return {
numRepos: gerritConfig.projects?.length,
hasToken: true, // gerrit doesn't use a token atm
}
}
default:
return {
numRepos: undefined,
hasToken: true
}
}
})();
if (!hasToken && numRepos && numRepos > CONFIG_MAX_REPOS_NO_TOKEN) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `You must provide a token to sync more than ${CONFIG_MAX_REPOS_NO_TOKEN} repositories.`,
} satisfies ServiceError;
}
const isValidConfig = ajv.validate(schema, parsedConfig);
if (!isValidConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
} satisfies ServiceError;
}
return parsedConfig;
}

View file

@ -10,7 +10,7 @@ import {
CommandList,
} from "@/components/ui/command"
import { Button } from "@/components/ui/button";
import { cn, isServiceError } from "@/lib/utils";
import { cn, isServiceError, unwrapServiceError } from "@/lib/utils";
import { ChevronsUpDown, Check, PlusCircleIcon, Loader2, Eye, EyeOff, TriangleAlert } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
@ -49,9 +49,9 @@ export const SecretCombobox = ({
const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
const captureEvent = useCaptureEvent();
const { data: secrets, isLoading, refetch } = useQuery({
const { data: secrets, isPending, isError, refetch } = useQuery({
queryKey: ["secrets"],
queryFn: () => getSecrets(domain),
queryFn: () => unwrapServiceError(getSecrets(domain)),
});
const onSecretCreated = useCallback((key: string) => {
@ -59,16 +59,6 @@ export const SecretCombobox = ({
refetch();
}, [onSecretChange, refetch]);
const isSecretNotFoundWarningVisible = useMemo(() => {
if (!isDefined(secretKey)) {
return false;
}
if (isServiceError(secrets)) {
return false;
}
return !secrets?.some(({ key }) => key === secretKey);
}, [secretKey, secrets]);
return (
<>
<Popover>
@ -83,7 +73,7 @@ export const SecretCombobox = ({
)}
disabled={isDisabled}
>
{isSecretNotFoundWarningVisible && (
{!(isPending || isError) && isDefined(secretKey) && !secrets.some(({ key }) => key === secretKey) && (
<TooltipProvider>
<Tooltip
@ -105,12 +95,13 @@ export const SecretCombobox = ({
</Button>
</PopoverTrigger>
<PopoverContent className="p-0.5">
{isLoading && (
{isPending ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{secrets && !isServiceError(secrets) && secrets.length > 0 && (
) : isError ? (
<p className="p-2 text-sm text-destructive">Failed to load secrets</p>
) : secrets.length > 0 && (
<>
<Command className="mb-2">
<CommandInput

View file

@ -4,167 +4,121 @@ import Link from "next/link";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { CircleXIcon } from "lucide-react";
import { useDomain } from "@/hooks/useDomain";
import { getConnectionFailedRepos, getConnections } from "@/actions";
import { useState, useEffect } from "react";
import { isServiceError } from "@/lib/utils";
import { unwrapServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
enum ConnectionErrorType {
SYNC_FAILED = "SYNC_FAILED",
REPO_INDEXING_FAILED = "REPO_INDEXING_FAILED",
}
interface Error {
connectionId?: number;
connectionName?: string;
errorType: ConnectionErrorType;
numRepos?: number;
}
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
import { useQuery } from "@tanstack/react-query";
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
import { getConnections } from "@/actions";
import { getRepos } from "@/actions";
export const ErrorNavIndicator = () => {
const domain = useDomain();
const [errors, setErrors] = useState<Error[]>([]);
const captureEvent = useCaptureEvent();
useEffect(() => {
const fetchErrors = async () => {
const connections = await getConnections(domain);
const errors: Error[] = [];
if (!isServiceError(connections)) {
for (const connection of connections) {
if (connection.syncStatus === 'FAILED') {
errors.push({
connectionId: connection.id,
connectionName: connection.name,
errorType: ConnectionErrorType.SYNC_FAILED
});
}
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
queryKey: ['repos', domain],
queryFn: () => unwrapServiceError(getRepos(domain)),
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
if (!isServiceError(failedRepos)) {
if (failedRepos.length > 0) {
errors.push({
connectionId: connection.id,
connectionName: connection.name,
numRepos: failedRepos.length,
errorType: ConnectionErrorType.REPO_INDEXING_FAILED
});
}
} else {
captureEvent('wa_error_nav_job_fetch_fail', {
error: failedRepos.errorCode,
});
}
}
} else {
captureEvent('wa_error_nav_connection_fetch_fail', {
error: connections.errorCode,
});
}
setErrors(prevErrors => {
// Only update if the errors have actually changed
const errorsChanged = prevErrors.length !== errors.length ||
prevErrors.some((error, idx) =>
error.connectionId !== errors[idx]?.connectionId ||
error.connectionName !== errors[idx]?.connectionName ||
error.errorType !== errors[idx]?.errorType
);
return errorsChanged ? errors : prevErrors;
});
};
const { data: connections, isPending: isPendingConnections, isError: isErrorConnections } = useQuery({
queryKey: ['connections', domain],
queryFn: () => unwrapServiceError(getConnections(domain)),
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.FAILED),
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
fetchErrors();
}, [domain, captureEvent]);
if (isPendingRepos || isErrorRepos || isPendingConnections || isErrorConnections) {
return null;
}
if (errors.length === 0) return null;
if (repos.length === 0 && connections.length === 0) {
return null;
}
return (
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
<CircleXIcon className="h-4 w-4" />
{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0) > 0 && (
<span>{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0)}</span>
{repos.length + connections.length > 0 && (
<span>{repos.length + connections.length}</span>
)}
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex flex-col gap-6 p-5">
{errors.filter(e => e.errorType === 'SYNC_FAILED').length > 0 && (
<div className="flex flex-col gap-4 border-b border-red-200 dark:border-red-800 pb-6">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Connection Sync Issues</h3>
</div>
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
The following connections have failed to sync:
</p>
<div className="flex flex-col gap-2 pl-4">
{errors
.filter(e => e.errorType === 'SYNC_FAILED')
.slice(0, 10)
.map(error => (
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
</Link>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex flex-col gap-6 p-5">
{connections.length > 0 && (
<div className="flex flex-col gap-4 pb-6">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Connection Sync Issues</h3>
</div>
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
The following connections have failed to sync:
</p>
<div className="flex flex-col gap-2">
{connections
.slice(0, 10)
.map(connection => (
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
rounded-md text-sm text-red-700 dark:text-red-300
border border-red-200/50 dark:border-red-800/50
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
<span className="font-medium">{error.connectionName}</span>
</div>
</Link>
))}
{errors.filter(e => e.errorType === 'SYNC_FAILED').length > 10 && (
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
And {errors.filter(e => e.errorType === 'SYNC_FAILED').length - 10} more...
</div>
)}
</div>
<span className="font-medium">{connection.name}</span>
</div>
</Link>
))}
{connections.length > 10 && (
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
And {connections.length - 10} more...
</div>
)}
</div>
)}
</div>
)}
{errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Repository Indexing Issues</h3>
</div>
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
The following connections have repositories that failed to index:
</p>
<div className="flex flex-col gap-2 pl-4">
{errors
.filter(e => e.errorType === 'REPO_INDEXING_FAILED')
.slice(0, 10)
.map(error => (
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
<div className="flex items-center justify-between px-3 py-2
{repos.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Repository Indexing Issues</h3>
</div>
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
The following repositories failed to index:
</p>
<div className="flex flex-col gap-2">
{repos
.slice(0, 10)
.map(repo => (
// Link to the first connection for the repo
<Link key={repo.repoId} href={`/${domain}/connections/${repo.linkedConnections[0]}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
<div className="flex items-center justify-between px-3 py-2
bg-red-50 dark:bg-red-900/20 rounded-md
border border-red-200/50 dark:border-red-800/50
hover:bg-red-100 dark:hover:bg-red-900/30
transition-colors">
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{error.connectionName}
</span>
<span className="text-xs font-medium px-2.5 py-1 rounded-full
bg-red-100/80 dark:bg-red-800/60
text-red-600 dark:text-red-300">
{error.numRepos}
</span>
</div>
</Link>
))}
{errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length > 10 && (
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
And {errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length - 10} more...
</div>
)}
</div>
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{repo.repoName}
</span>
</div>
</Link>
))}
{repos.length > 10 && (
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
And {repos.length - 10} more...
</div>
)}
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
</Link>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
);
};

View file

@ -1,73 +1,41 @@
"use client";
import Link from "next/link";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { useDomain } from "@/hooks/useDomain";
import { getConnectionInProgressRepos, getConnections } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { getRepos } from "@/actions";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface InProgress {
connectionId: number;
repoId: number;
repoName: string;
}
import { useDomain } from "@/hooks/useDomain";
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
import { unwrapServiceError } from "@/lib/utils";
import { RepoIndexingStatus } from "@prisma/client";
import { useQuery } from "@tanstack/react-query";
import { Loader2Icon } from "lucide-react";
import Link from "next/link";
export const ProgressNavIndicator = () => {
const domain = useDomain();
const [inProgressJobs, setInProgressJobs] = useState<InProgress[]>([]);
const captureEvent = useCaptureEvent();
useEffect(() => {
const fetchInProgressJobs = async () => {
const connections = await getConnections(domain);
if (!isServiceError(connections)) {
const allInProgressRepos: InProgress[] = [];
for (const connection of connections) {
const inProgressRepos = await getConnectionInProgressRepos(connection.id, domain);
if (!isServiceError(inProgressRepos)) {
allInProgressRepos.push(...inProgressRepos.map(repo => ({
connectionId: connection.id,
...repo
})));
} else {
captureEvent('wa_progress_nav_job_fetch_fail', {
error: inProgressRepos.errorCode,
});
}
}
setInProgressJobs(prevJobs => {
// Only update if the jobs have actually changed
const jobsChanged = prevJobs.length !== allInProgressRepos.length ||
prevJobs.some((job, idx) =>
job.repoId !== allInProgressRepos[idx]?.repoId ||
job.repoName !== allInProgressRepos[idx]?.repoName
);
return jobsChanged ? allInProgressRepos : prevJobs;
});
} else {
captureEvent('wa_progress_nav_connection_fetch_fail', {
error: connections.errorCode,
});
}
};
const { data: inProgressRepos, isPending, isError } = useQuery({
queryKey: ['repos', domain],
queryFn: () => unwrapServiceError(getRepos(domain)),
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
fetchInProgressJobs();
}, [domain, captureEvent]);
if (inProgressJobs.length === 0) {
if (isPending || isError || inProgressRepos.length === 0) {
return null;
}
return (
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_progress_nav_pressed', {})}>
<Link
href={`/${domain}/connections`}
onClick={() => captureEvent('wa_progress_nav_pressed', {})}
>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
<Loader2Icon className="h-4 w-4 animate-spin" />
<span>{inProgressJobs.length}</span>
<span>{inProgressRepos.length}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-green-200 dark:border-green-800 rounded-lg">
@ -80,8 +48,9 @@ export const ProgressNavIndicator = () => {
The following repositories are currently being indexed:
</p>
<div className="flex flex-col gap-2 pl-4">
{inProgressJobs.slice(0, 10).map(item => (
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`} onClick={() => captureEvent('wa_progress_nav_job_pressed', {})}>
{inProgressRepos.slice(0, 10).map(item => (
// Link to the first connection for the repo
<Link key={item.repoId} href={`/${domain}/connections/${item.linkedConnections[0]}`} onClick={() => captureEvent('wa_progress_nav_job_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
rounded-md text-sm text-green-700 dark:text-green-300
border border-green-200/50 dark:border-green-800/50
@ -90,9 +59,9 @@ export const ProgressNavIndicator = () => {
</div>
</Link>
))}
{inProgressJobs.length > 10 && (
{inProgressRepos.length > 10 && (
<div className="text-sm text-green-600/90 dark:text-green-300/90 pl-3 pt-1">
And {inProgressJobs.length - 10} more...
And {inProgressRepos.length - 10} more...
</div>
)}
</div>

View file

@ -5,56 +5,24 @@ import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/h
import { AlertTriangleIcon } from "lucide-react";
import { useDomain } from "@/hooks/useDomain";
import { getConnections } from "@/actions";
import { useState } from "react";
import { useEffect } from "react";
import { isServiceError } from "@/lib/utils";
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema";
import { unwrapServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface Warning {
connectionId?: number;
connectionName?: string;
}
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
import { useQuery } from "@tanstack/react-query";
import { ConnectionSyncStatus } from "@prisma/client";
export const WarningNavIndicator = () => {
const domain = useDomain();
const [warnings, setWarnings] = useState<Warning[]>([]);
const captureEvent = useCaptureEvent();
useEffect(() => {
const fetchWarnings = async () => {
const connections = await getConnections(domain);
const warnings: Warning[] = [];
if (!isServiceError(connections)) {
for (const connection of connections) {
const parseResult = SyncStatusMetadataSchema.safeParse(connection.syncStatusMetadata);
if (parseResult.success && parseResult.data.notFound) {
const { notFound } = parseResult.data;
if (notFound.users.length > 0 || notFound.orgs.length > 0 || notFound.repos.length > 0) {
warnings.push({ connectionId: connection.id, connectionName: connection.name });
}
}
}
} else {
captureEvent('wa_warning_nav_connection_fetch_fail', {
error: connections.errorCode,
});
}
const { data: connections, isPending, isError } = useQuery({
queryKey: ['connections', domain],
queryFn: () => unwrapServiceError(getConnections(domain)),
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.SYNCED_WITH_WARNINGS),
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
setWarnings(prevWarnings => {
// Only update if the warnings have actually changed
const warningsChanged = prevWarnings.length !== warnings.length ||
prevWarnings.some((warning, idx) =>
warning.connectionId !== warnings[idx]?.connectionId ||
warning.connectionName !== warnings[idx]?.connectionName
);
return warningsChanged ? warnings : prevWarnings;
});
};
fetchWarnings();
}, [domain, captureEvent]);
if (warnings.length === 0) {
if (isPending || isError || connections.length === 0) {
return null;
}
@ -64,7 +32,7 @@ export const WarningNavIndicator = () => {
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
<AlertTriangleIcon className="h-4 w-4" />
<span>{warnings.length}</span>
<span>{connections.length}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
@ -77,19 +45,19 @@ export const WarningNavIndicator = () => {
The following connections have references that could not be found:
</p>
<div className="flex flex-col gap-2 pl-4">
{warnings.slice(0, 10).map(warning => (
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
{connections.slice(0, 10).map(connection => (
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
rounded-md text-sm text-yellow-700 dark:text-yellow-300
border border-yellow-200/50 dark:border-yellow-800/50
hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
<span className="font-medium">{warning.connectionName}</span>
<span className="font-medium">{connection.name}</span>
</div>
</Link>
))}
{warnings.length > 10 && (
{connections.length > 10 && (
<div className="text-sm text-yellow-600/90 dark:text-yellow-300/90 pl-3 pt-1">
And {warnings.length - 10} more...
And {connections.length - 10} more...
</div>
)}
</div>

View file

@ -154,13 +154,6 @@ function ConfigSettingInternal<T>({
onConfigChange(config);
}, [config, onConfigChange]);
useEffect(() => {
console.log("mount");
return () => {
console.log("unmount");
}
}, []);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-2">Configuration</h3>

View file

@ -1,68 +1,80 @@
'use client';
import { AlertTriangle } from "lucide-react"
import { Prisma } from "@sourcebot/db"
import { RetrySyncButton } from "./retrySyncButton"
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 {
syncStatusMetadata: Prisma.JsonValue
onSecretsClick: () => void
connectionId: number
domain: string
connectionType: string
syncStatus: ConnectionSyncStatus
syncStatusMetadata: Prisma.JsonValue
onSecretsClick: () => void
connectionType: string
onRetrySync: () => void
}
export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connectionId, domain, connectionType }: NotFoundWarningProps) => {
const captureEvent = useCaptureEvent();
export const NotFoundWarning = ({ syncStatus, syncStatusMetadata, onSecretsClick, connectionType, onRetrySync }: NotFoundWarningProps) => {
const captureEvent = useCaptureEvent();
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
if (!parseResult.success || !parseResult.data.notFound) {
return null;
}
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
if (syncStatus !== ConnectionSyncStatus.SYNCED_WITH_WARNINGS || !parseResult.success || !parseResult.data.notFound) {
return null;
}
const { notFound } = parseResult.data;
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', {});
}
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">
<RetrySyncButton connectionId={connectionId} domain={domain} />
</div>
</div>
)
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

@ -0,0 +1,159 @@
'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 { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
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: 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, toast, 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

@ -0,0 +1,229 @@
'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 { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
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(domain, { connectionId }));
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: 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), domain)
.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, domain, 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 max-h-96 overflow-scroll">
{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

@ -0,0 +1,15 @@
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

@ -3,42 +3,42 @@
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagRepoForIndex } from "@/actions";
import { flagReposForIndex } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetryRepoIndexButtonProps {
repoId: number;
domain: string;
repoId: number;
domain: string;
}
export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => {
const captureEvent = useCaptureEvent();
const captureEvent = useCaptureEvent();
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
const result = await flagRepoForIndex(repoId, domain);
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>
);
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
const result = await flagReposForIndex([repoId], domain);
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,75 +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 { flagRepoForIndex, getConnectionFailedRepos } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetryAllFailedReposButtonProps {
connectionId: number;
domain: string;
}
export const RetryAllFailedReposButton = ({ connectionId, domain }: RetryAllFailedReposButtonProps) => {
const captureEvent = useCaptureEvent();
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
captureEvent('wa_connection_retry_all_failed_repos_pressed', {});
const failedRepos = await getConnectionFailedRepos(connectionId, domain);
if (isServiceError(failedRepos)) {
toast({
description: `❌ Failed to get failed repositories.`,
});
captureEvent('wa_connection_retry_all_failed_repos_fetch_fail', {
error: failedRepos.errorCode,
});
return;
}
let successCount = 0;
let failureCount = 0;
for (const repo of failedRepos) {
const result = await flagRepoForIndex(repo.repoId, domain);
if (isServiceError(result)) {
failureCount++;
} else {
successCount++;
}
}
if (failureCount > 0) {
toast({
description: `⚠️ ${successCount} repositories flagged for indexing, ${failureCount} failed.`,
});
captureEvent('wa_connection_retry_all_failed_repos_fail', {
successCount,
failureCount,
});
} else if (successCount > 0) {
toast({
description: `${successCount} repositories flagged for indexing.`,
});
captureEvent('wa_connection_retry_all_failed_repos_success', {
successCount,
});
} else {
toast({
description: " No failed repositories to retry.",
});
captureEvent('wa_connection_retry_all_failed_no_repos', {});
}
}}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry All Failed
</Button>
);
};

View file

@ -1,44 +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 { flagConnectionForSync } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetrySyncButtonProps {
connectionId: number;
domain: string;
}
export const RetrySyncButton = ({ connectionId, domain }: RetrySyncButtonProps) => {
const captureEvent = useCaptureEvent();
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={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', {});
}
}}
>
<ReloadIcon className="h-4 w-4 mr-2" />
Retry Sync
</Button>
);
};

View file

@ -1,5 +1,3 @@
"use client"
import { NotFound } from "@/app/[domain]/components/notFound"
import {
Breadcrumb,
@ -9,7 +7,6 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { ScrollArea } from "@/components/ui/scroll-area"
import { TabSwitcher } from "@/components/ui/tab-switcher"
import { Tabs, TabsContent } from "@/components/ui/tabs"
import { ConnectionIcon } from "../components/connectionIcon"
@ -17,113 +14,33 @@ import { Header } from "../../components/header"
import { ConfigSetting } from "./components/configSetting"
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
import { DisplayNameSetting } from "./components/displayNameSetting"
import { RepoListItem } from "./components/repoListItem"
import { useParams, useSearchParams, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import type { Connection, Repo, Org } from "@sourcebot/db"
import { getConnectionInfoAction, getOrgFromDomainAction } from "@/actions"
import { isServiceError } from "@/lib/utils"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { DisplayConnectionError } from "./components/connectionError"
import { NotFoundWarning } from "./components/notFoundWarning"
import { RetrySyncButton } from "./components/retrySyncButton"
import { RetryAllFailedReposButton } from "./components/retryAllFailedReposButton"
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { RepoList } from "./components/repoList"
import { auth } from "@/auth"
import { getConnectionByDomain } from "@/data/connection"
import { Overview } from "./components/overview"
export default function ConnectionManagementPage() {
const params = useParams()
const searchParams = useSearchParams()
const router = useRouter()
const [org, setOrg] = useState<Org | null>(null)
const [connection, setConnection] = useState<Connection | null>(null)
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const captureEvent = useCaptureEvent();
interface ConnectionManagementPageProps {
params: {
domain: string
id: string
},
searchParams: {
tab: string
}
}
const handleSecretsNavigation = () => {
captureEvent('wa_connection_secrets_navigation_pressed', {});
router.push(`/${params.domain}/secrets`)
export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) {
const session = await auth();
if (!session) {
return null;
}
useEffect(() => {
const loadData = async () => {
try {
const orgResult = await getOrgFromDomainAction(params.domain as string)
if (isServiceError(orgResult)) {
setError(orgResult.message)
setLoading(false)
return
}
setOrg(orgResult)
const connectionId = Number(params.id)
if (isNaN(connectionId)) {
setError("Invalid connection ID")
setLoading(false)
return
}
const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string)
if (isServiceError(connectionInfoResult)) {
setError(connectionInfoResult.message)
setLoading(false)
return
}
connectionInfoResult.linkedRepos.sort((a, b) => {
// Helper function to get priority of indexing status
const getPriority = (status: string) => {
switch (status) {
case "FAILED":
return 0
case "IN_INDEX_QUEUE":
case "INDEXING":
return 1
case "INDEXED":
return 2
default:
return 3
}
}
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 createdAt
return new Date(a.indexedAt ?? new Date()).getTime() - new Date(b.indexedAt ?? new Date()).getTime()
})
setConnection(connectionInfoResult.connection)
setLinkedRepos(connectionInfoResult.linkedRepos)
setLoading(false)
} catch (err) {
setError(
err instanceof Error
? err.message
: "An error occurred while loading the connection. If the problem persists, please contact us at team@sourcebot.dev",
)
setLoading(false)
}
}
loadData()
}, [params.domain, params.id])
if (loading) {
return <div>Loading...</div>
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" />
}
if (error || !org || !connection) {
return <NotFound className="flex w-full h-full items-center justify-center" message={error || "Not found"} />
}
const currentTab = searchParams.get("tab") || "overview"
const currentTab = searchParams.tab || "overview";
return (
<Tabs value={currentTab} className="w-full">
@ -154,75 +71,17 @@ export default function ConnectionManagementPage() {
</Header>
<TabsContent
value="overview"
className="space-y-8"
>
<h1 className="font-semibold text-lg">Overview</h1>
<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">{linkedRepos.length}</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 asChild onMouseEnter={() => captureEvent('wa_connection_failed_status_hover', {})}>
<div className="flex items-center">
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-600/20 cursor-help hover:text-red-600 hover:bg-red-100 transition-colors duration-200">
{connection.syncStatus}
</span>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<DisplayConnectionError
syncStatusMetadata={connection.syncStatusMetadata}
onSecretsClick={handleSecretsNavigation}
/>
</HoverCardContent>
</HoverCard>
) : (
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{connection.syncStatus}
</span>
)}
{connection.syncStatus === "FAILED" && (
<RetrySyncButton connectionId={connection.id} domain={params.domain as string} />
)}
</div>
</div>
</div>
<NotFoundWarning syncStatusMetadata={connection.syncStatusMetadata} onSecretsClick={handleSecretsNavigation} connectionId={connection.id} connectionType={connection.connectionType} domain={params.domain as string} />
<div>
<h1 className="font-semibold text-lg mb-4">Overview</h1>
<Overview connectionId={connection.id} />
</div>
<div className="flex justify-between items-center mt-8">
<h1 className="font-semibold text-lg">Linked Repositories</h1>
<RetryAllFailedReposButton connectionId={connection.id} domain={params.domain as string} />
<div>
<h1 className="font-semibold text-lg mb-4">Linked Repositories</h1>
<RepoList connectionId={connection.id} />
</div>
<ScrollArea className="mt-4 max-h-96 overflow-scroll">
<div className="flex flex-col gap-4">
{linkedRepos.map((repo) => (
<RepoListItem
key={repo.id}
imageUrl={repo.imageUrl ?? undefined}
name={repo.name}
indexedAt={repo.indexedAt ?? undefined}
status={repo.repoIndexingStatus}
repoId={repo.id}
domain={params.domain as string}
/>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="settings"

View file

@ -16,6 +16,8 @@ const convertSyncStatus = (status: ConnectionSyncStatus) => {
return 'running';
case ConnectionSyncStatus.SYNCED:
return 'succeeded';
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
return 'succeeded-with-warnings';
case ConnectionSyncStatus.FAILED:
return 'failed';
}
@ -53,6 +55,8 @@ export const ConnectionListItem = ({
return 'Synced';
case ConnectionSyncStatus.FAILED:
return 'Sync failed';
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
return null;
}
}, [status]);

View file

@ -1,75 +1,114 @@
"use client";
import { useDomain } from "@/hooks/useDomain";
import { ConnectionListItem } from "./connectionListItem";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { cn, unwrapServiceError } from "@/lib/utils";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
import { getConnectionFailedRepos, getConnections } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { getConnections } from "@/actions";
import { Skeleton } from "@/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
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;
}
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,
}: ConnectionListProps) => {
const domain = useDomain();
const [connections, setConnections] = useState<{
id: number;
name: string;
connectionType: string;
syncStatus: ConnectionSyncStatus;
syncStatusMetadata: Prisma.JsonValue;
updatedAt: Date;
syncedAt?: Date;
failedRepos?: { repoId: number, repoName: string }[];
}[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
useEffect(() => {
const fetchConnections = async () => {
try {
const result = await getConnections(domain);
if (isServiceError(result)) {
setError(result.message);
} else {
const connectionsWithFailedRepos = [];
for (const connection of result) {
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
if (isServiceError(failedRepos)) {
setError(`An error occured while fetching the failed repositories for connection ${connection.name}. If the problem persists, please contact us at team@sourcebot.dev`);
} else {
connectionsWithFailedRepos.push({
...connection,
failedRepos,
});
}
}
setConnections(connectionsWithFailedRepos);
const { data: unfilteredConnections, isPending, error } = useQuery({
queryKey: ['connections', domain],
queryFn: () => unwrapServiceError(getConnections(domain)),
refetchInterval: 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;
}
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occured while fetching connections. If the problem persists, please contact us at team@sourcebot.dev');
setLoading(false);
}
};
fetchConnections();
}, [domain]);
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)}>
{loading ? (
<div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
<p>Loading connections...</p>
<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>
) : error ? (
<div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
<p>Error loading connections: {error}</p>
<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
@ -84,7 +123,10 @@ export const ConnectionList = ({
syncStatusMetadata={connection.syncStatusMetadata}
editedAt={connection.updatedAt}
syncedAt={connection.syncedAt ?? undefined}
failedRepos={connection.failedRepos}
failedRepos={connection.linkedRepos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
repoId: repo.id,
repoName: repo.name,
}))}
/>
))
) : (
@ -94,5 +136,5 @@ export const ConnectionList = ({
</div>
)}
</div>
)
);
}

View file

@ -3,7 +3,7 @@ import { CircleCheckIcon, CircleXIcon } from "lucide-react";
import { useMemo } from "react";
import { FiLoader } from "react-icons/fi";
export type Status = 'waiting' | 'running' | 'succeeded' | 'failed' | 'garbage-collecting';
export type Status = 'waiting' | 'running' | 'succeeded' | 'succeeded-with-warnings' | 'garbage-collecting' | 'failed';
export const StatusIcon = ({
status,
@ -19,7 +19,9 @@ export const StatusIcon = ({
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]);

View file

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,370 @@
// src/components/multi-select.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
CheckIcon,
XCircle,
ChevronDown,
XIcon,
WandSparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
/**
* Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
*/
const multiSelectVariants = cva(
"m-1 transition ease-in-out",
{
variants: {
variant: {
default:
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
secondary:
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
inverted: "inverted",
},
},
defaultVariants: {
variant: "default",
},
}
);
/**
* Props for MultiSelect component
*/
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
* Each option object has a label, value, and an optional icon.
*/
options: {
/** The text to display for the option. */
label: string;
/** The unique value associated with the option. */
value: string;
/** Optional icon component to display alongside the option. */
icon?: React.ComponentType<{ className?: string }>;
}[];
/**
* Callback function triggered when the selected values change.
* Receives an array of the new selected values.
*/
onValueChange: (value: string[]) => void;
/** The default selected values when the component mounts. */
defaultValue?: string[];
/**
* Placeholder text to be displayed when no values are selected.
* Optional, defaults to "Select options".
*/
placeholder?: string;
/**
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
* Optional, defaults to 0 (no animation).
*/
animation?: number;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
/**
* The modality of the popover. When set to true, interaction with outside elements
* will be disabled and only popover content will be visible to screen readers.
* Optional, defaults to false.
*/
modalPopover?: boolean;
/**
* If true, renders the multi-select component as a child of another component.
* Optional, defaults to false.
*/
asChild?: boolean;
/**
* Additional class names to apply custom styles to the multi-select component.
* Optional, can be used to add custom styles.
*/
className?: string;
}
export const MultiSelect = React.forwardRef<
HTMLButtonElement,
MultiSelectProps
>(
(
{
options,
onValueChange,
variant,
defaultValue = [],
placeholder = "Select options",
animation = 0,
maxCount = 3,
modalPopover = false,
asChild = false,
className,
...props
},
ref
) => {
const [selectedValues, setSelectedValues] =
React.useState<string[]>(defaultValue);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const toggleOption = (option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const handleClear = () => {
setSelectedValues([]);
onValueChange([]);
};
const handleTogglePopover = () => {
setIsPopoverOpen((prev) => !prev);
};
const clearExtraOptions = () => {
const newSelectedValues = selectedValues.slice(0, maxCount);
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const toggleAll = () => {
if (selectedValues.length === options.length) {
handleClear();
} else {
const allValues = options.map((option) => option.value);
setSelectedValues(allValues);
onValueChange(allValues);
}
};
return (
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
modal={modalPopover}
>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto",
className
)}
>
{selectedValues.length > 0 ? (
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
const IconComponent = option?.icon;
return (
<Badge
key={value}
className={cn(
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{IconComponent && (
<IconComponent className="h-4 w-4 mr-2" />
)}
{option?.label}
<XCircle
className="ml-2 h-4 w-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleOption(value);
}}
/>
</Badge>
);
})}
{selectedValues.length > maxCount && (
<Badge
className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{`+ ${selectedValues.length - maxCount} more`}
<XCircle
className="ml-2 h-4 w-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
clearExtraOptions();
}}
/>
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<XIcon
className="h-4 mx-2 cursor-pointer text-muted-foreground"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span
className="text-sm text-muted-foreground mx-3"
style={{
fontWeight: 400
}}
>
{placeholder}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption(option.value)}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
</>
)}
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 justify-center cursor-pointer max-w-full"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
{animation > 0 && selectedValues.length > 0 && (
<WandSparkles
className={cn(
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
isAnimating ? "" : "text-muted-foreground"
)}
onClick={() => setIsAnimating(!isAnimating)}
/>
)}
</Popover>
);
}
);
MultiSelect.displayName = "MultiSelect";

View file

@ -12,18 +12,15 @@ export const getConnection = async (connectionId: number, orgId: number) => {
return connection;
}
export const getLinkedRepos = async (connectionId: number, orgId: number) => {
const linkedRepos = await prisma.repoToConnection.findMany({
export const getConnectionByDomain = async (connectionId: number, domain: string) => {
const connection = await prisma.connection.findUnique({
where: {
connection: {
id: connectionId,
orgId: orgId,
id: connectionId,
org: {
domain: domain,
}
},
include: {
repo: true,
}
});
return linkedRepos;
}
return connection;
}

View file

@ -1,5 +1,5 @@
import { prisma } from '@/prisma';
import 'server-only';
import { prisma } from '@/prisma';
export const getOrgFromDomain = async (domain: string) => {
const org = await prisma.org.findUnique({

View file

@ -1,6 +1,6 @@
import 'client-only';
import { getEnv, getEnvBoolean } from "./utils";
import { getEnv, getEnvBoolean, getEnvNumber } from "./utils";
export const NEXT_PUBLIC_POSTHOG_PAPIK = getEnv(process.env.NEXT_PUBLIC_POSTHOG_PAPIK);
export const NEXT_PUBLIC_POSTHOG_HOST = getEnv(process.env.NEXT_PUBLIC_POSTHOG_HOST);
@ -9,4 +9,5 @@ export const NEXT_PUBLIC_POSTHOG_ASSET_HOST = getEnv(process.env.NEXT_PUBLIC_POS
export const NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED, false);
export const NEXT_PUBLIC_SOURCEBOT_VERSION = getEnv(process.env.NEXT_PUBLIC_SOURCEBOT_VERSION, "unknown")!;
export const NEXT_PUBLIC_DOMAIN_SUB_PATH = getEnv(process.env.NEXT_PUBLIC_DOMAIN_SUB_PATH, "")!;
export const NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = getEnv(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
export const NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = getEnv(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
export const NEXT_PUBLIC_POLLING_INTERVAL_MS = getEnvNumber(process.env.NEXT_PUBLIC_POLLING_INTERVAL_MS, 5000);

View file

@ -64,7 +64,7 @@ export type PosthogEventMap = {
wa_progress_nav_connection_fetch_fail: {
error: string,
},
wa_progress_nav_job_fetch_fail: {
wa_progress_nav_repo_fetch_fail: {
error: string,
},
wa_progress_nav_hover: {},
@ -205,13 +205,8 @@ export type PosthogEventMap = {
wa_connection_retry_all_failed_repos_fetch_fail: {
error: string,
},
wa_connection_retry_all_failed_repos_fail: {
successCount: number,
failureCount: number,
},
wa_connection_retry_all_failed_repos_success: {
successCount: number,
},
wa_connection_retry_all_failed_repos_fail: {},
wa_connection_retry_all_failed_repos_success: {},
wa_connection_retry_all_failed_no_repos: {},
//////////////////////////////////////////////////////////////////
wa_repo_retry_index_success: {},

View file

@ -244,3 +244,20 @@ export const measure = async <T>(cb: () => Promise<T>, measureName: string) => {
durationMs
}
}
/**
* Unwraps a promise that could return a ServiceError, throwing an error if it does.
* This is useful for calling server actions in a useQuery hook since it allows us
* to take advantage of error handling behavior built into react-query.
*
* @param promise The promise to unwrap.
* @returns The data from the promise.
*/
export const unwrapServiceError = async <T>(promise: Promise<ServiceError | T>): Promise<T> => {
const data = await promise;
if (isServiceError(data)) {
throw new Error(data.message);
}
return data;
}