diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 835123e0..e09a7915 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -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) { + private async runSyncJob(job: Job): Promise { 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 = (await this.db.connection.findUnique({ + where: { id: connectionId }, + select: { syncStatusMetadata: true } + }))?.syncStatusMetadata as Record ?? {}; + 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() } }) diff --git a/packages/backend/src/gitea.ts b/packages/backend/src/gitea.ts index 5fd8f5ba..d7e89c6a 100644 --- a/packages/backend/src/gitea.ts +++ b/packages/backend/src/gitea.ts @@ -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}`, diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index 99a55fae..3c6d3096 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -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 ? { diff --git a/packages/db/prisma/migrations/20250225183123_add_synced_with_warnings_to_connection_sync_status/migration.sql b/packages/db/prisma/migrations/20250225183123_add_synced_with_warnings_to_connection_sync_status/migration.sql new file mode 100644 index 00000000..aed68c73 --- /dev/null +++ b/packages/db/prisma/migrations/20250225183123_add_synced_with_warnings_to_connection_sync_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ConnectionSyncStatus" ADD VALUE 'SYNCED_WITH_WARNINGS'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6c0d5fc7..cec632fd 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -26,6 +26,7 @@ enum ConnectionSyncStatus { IN_SYNC_QUEUE SYNCING SYNCED + SYNCED_WITH_WARNINGS FAILED } diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 6317d972..ff64ce98 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -26,11 +26,7 @@ const nextConfig = { remotePatterns: [ { protocol: 'https', - hostname: 'avatars.githubusercontent.com', - }, - { - protocol: 'https', - hostname: 'gitlab.com', + hostname: '**', }, ] } diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index f4b2296c..e3c90d01 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -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 linkedRepos = await getLinkedRepos(connectionId, orgId); + const repos = await prisma.repo.findMany({ + where: { + orgId, + ...(filter.status ? { + repoIndexingStatus: { in: filter.status } + } : {}), + ...(filter.connectionId ? { + connections: { + some: { + connectionId: filter.connectionId + } + } + } : {}), + }, + include: { + connections: true, + } + }); - 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 => - 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({ - where: { - id: repoId, - }, - }); - - if (!repo) { - return notFound(); - } - - await prisma.repo.update({ + withOrgMembership(session, domain, async ({ orgId }) => { + await prisma.repo.updateMany({ 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 => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { @@ -1104,32 +997,6 @@ export const fetchSubscription = (domain: string): Promise => { - 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 => 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 => { - const orgs = await prisma.userToOrg.findMany({ - where: { - userId, - }, - }); - - return orgs.length > 0; -} - export const checkIfOrgDomainExists = async (domain: string): Promise => 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 => { + 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; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx index 60ee5543..2fa64010 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx @@ -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 ( <> @@ -83,7 +73,7 @@ export const SecretCombobox = ({ )} disabled={isDisabled} > - {isSecretNotFoundWarningVisible && ( + {!(isPending || isError) && isDefined(secretKey) && !secrets.some(({ key }) => key === secretKey) && ( - {isLoading && ( + {isPending ? (
- )} - {secrets && !isServiceError(secrets) && secrets.length > 0 && ( + ) : isError ? ( +

Failed to load secrets

+ ) : secrets.length > 0 && ( <> { const domain = useDomain(); - const [errors, setErrors] = useState([]); 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 ( - captureEvent('wa_error_nav_pressed', {})}> - - captureEvent('wa_error_nav_hover', {})}> + + captureEvent('wa_error_nav_hover', {})}> + captureEvent('wa_error_nav_pressed', {})}>
- {errors.reduce((acc, error) => acc + (error.numRepos || 0), 0) > 0 && ( - {errors.reduce((acc, error) => acc + (error.numRepos || 0), 0)} + {repos.length + connections.length > 0 && ( + {repos.length + connections.length} )}
-
- -
- {errors.filter(e => e.errorType === 'SYNC_FAILED').length > 0 && ( -
-
-
-

Connection Sync Issues

-
-

- The following connections have failed to sync: -

-
- {errors - .filter(e => e.errorType === 'SYNC_FAILED') - .slice(0, 10) - .map(error => ( - captureEvent('wa_error_nav_job_pressed', {})}> -
+
+ {connections.length > 0 && ( +
+
+
+

Connection Sync Issues

+
+

+ The following connections have failed to sync: +

+
+ {connections + .slice(0, 10) + .map(connection => ( + captureEvent('wa_error_nav_job_pressed', {})}> +
- {error.connectionName} -
- - ))} - {errors.filter(e => e.errorType === 'SYNC_FAILED').length > 10 && ( -
- And {errors.filter(e => e.errorType === 'SYNC_FAILED').length - 10} more... -
- )} -
+ {connection.name} +
+ + ))} + {connections.length > 10 && ( +
+ And {connections.length - 10} more... +
+ )}
- )} +
+ )} - {errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length > 0 && ( -
-
-
-

Repository Indexing Issues

-
-

- The following connections have repositories that failed to index: -

-
- {errors - .filter(e => e.errorType === 'REPO_INDEXING_FAILED') - .slice(0, 10) - .map(error => ( - captureEvent('wa_error_nav_job_pressed', {})}> -
+
+
+

Repository Indexing Issues

+
+

+ The following repositories failed to index: +

+
+ {repos + .slice(0, 10) + .map(repo => ( + // Link to the first connection for the repo + captureEvent('wa_error_nav_job_pressed', {})}> +
- - {error.connectionName} - - - {error.numRepos} - -
- - ))} - {errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length > 10 && ( -
- And {errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length - 10} more... -
- )} -
+ + {repo.repoName} + +
+ + ))} + {repos.length > 10 && ( +
+ And {repos.length - 10} more... +
+ )}
- )} -
- - - +
+ )} +
+ + ); }; diff --git a/packages/web/src/app/[domain]/components/progressNavIndicator.tsx b/packages/web/src/app/[domain]/components/progressNavIndicator.tsx index de2ff062..5ea3be16 100644 --- a/packages/web/src/app/[domain]/components/progressNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/progressNavIndicator.tsx @@ -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([]); 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, - }); - } - }; - - fetchInProgressJobs(); - }, [domain, captureEvent]); + 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, + }); - if (inProgressJobs.length === 0) { + if (isPending || isError || inProgressRepos.length === 0) { return null; } return ( - captureEvent('wa_progress_nav_pressed', {})}> + captureEvent('wa_progress_nav_pressed', {})} + > captureEvent('wa_progress_nav_hover', {})}>
- {inProgressJobs.length} + {inProgressRepos.length}
@@ -80,8 +48,9 @@ export const ProgressNavIndicator = () => { The following repositories are currently being indexed:

- {inProgressJobs.slice(0, 10).map(item => ( - captureEvent('wa_progress_nav_job_pressed', {})}> + {inProgressRepos.slice(0, 10).map(item => ( + // Link to the first connection for the repo + captureEvent('wa_progress_nav_job_pressed', {})}>
- And {inProgressJobs.length - 10} more... + And {inProgressRepos.length - 10} more...
)}
diff --git a/packages/web/src/app/[domain]/components/warningNavIndicator.tsx b/packages/web/src/app/[domain]/components/warningNavIndicator.tsx index a4a9a89e..26363f1a 100644 --- a/packages/web/src/app/[domain]/components/warningNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/warningNavIndicator.tsx @@ -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([]); 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 = () => { captureEvent('wa_warning_nav_hover', {})}>
- {warnings.length} + {connections.length}
@@ -77,19 +45,19 @@ export const WarningNavIndicator = () => { The following connections have references that could not be found:

- {warnings.slice(0, 10).map(warning => ( - captureEvent('wa_warning_nav_connection_pressed', {})}> + {connections.slice(0, 10).map(connection => ( + captureEvent('wa_warning_nav_connection_pressed', {})}>
- {warning.connectionName} + {connection.name}
))} - {warnings.length > 10 && ( + {connections.length > 10 && (
- And {warnings.length - 10} more... + And {connections.length - 10} more...
)}
diff --git a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx index f173475c..f9d2feba 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx @@ -154,13 +154,6 @@ function ConfigSettingInternal({ onConfigChange(config); }, [config, onConfigChange]); - useEffect(() => { - console.log("mount"); - return () => { - console.log("unmount"); - } - }, []); - return (

Configuration

diff --git a/packages/web/src/app/[domain]/connections/[id]/components/notFoundWarning.tsx b/packages/web/src/app/[domain]/connections/[id]/components/notFoundWarning.tsx index 6df5e41e..c65d4f10 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/notFoundWarning.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/notFoundWarning.tsx @@ -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 ( -
-
- -

Unable to fetch all references

-
-

- Some requested references couldn't be found. Please ensure you've provided the information listed below correctly, and that you've provided a{" "} - {" "} - to access them if they're private. -

-
    - {notFound.users.length > 0 && ( -
  • - Users: - {notFound.users.join(', ')} -
  • - )} - {notFound.orgs.length > 0 && ( -
  • - {connectionType === "gitlab" ? "Groups" : "Organizations"}: - {notFound.orgs.join(', ')} -
  • - )} - {notFound.repos.length > 0 && ( -
  • - {connectionType === "gitlab" ? "Projects" : "Repositories"}: - {notFound.repos.join(', ')} -
  • - )} -
-
- -
-
- ) + return ( +
+
+ +

Unable to fetch all references

+
+

+ Some requested references couldn't be found. Please ensure you've provided the information listed below correctly, and that you've provided a{" "} + {" "} + to access them if they're private. +

+
    + {notFound.users.length > 0 && ( +
  • + Users: + {notFound.users.join(', ')} +
  • + )} + {notFound.orgs.length > 0 && ( +
  • + {connectionType === "gitlab" ? "Groups" : "Organizations"}: + {notFound.orgs.join(', ')} +
  • + )} + {notFound.repos.length > 0 && ( +
  • + {connectionType === "gitlab" ? "Projects" : "Repositories"}: + {notFound.repos.join(', ')} +
  • + )} +
+
+ +
+
+ ) } diff --git a/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx b/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx new file mode 100644 index 00000000..5811649f --- /dev/null +++ b/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx @@ -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
+ {`Error loading connection. Reason: ${error.message}`} +
+ } + + if (isPending) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) + } + + return ( +
+
+
+

Connection Type

+

{connection.connectionType}

+
+
+

Last Synced At

+

+ {connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : "never"} +

+
+
+

Linked Repositories

+

{connection.numLinkedRepos}

+
+
+

Status

+
+ {connection.syncStatus === "FAILED" ? ( + + captureEvent('wa_connection_failed_status_hover', {})}> + + + + + + + ) : ( + + )} + {connection.syncStatus === "FAILED" && ( + + )} +
+
+
+ +
+ ) +} + +const SyncStatusBadge = ({ status }: { status: ConnectionSyncStatus }) => { + return ( + + {status === ConnectionSyncStatus.SYNC_NEEDED || status === ConnectionSyncStatus.IN_SYNC_QUEUE ? ( + <> Sync queued + ) : status === ConnectionSyncStatus.SYNCING ? ( + <> Syncing + ) : status === ConnectionSyncStatus.SYNCED ? ( + Synced + ) : status === ConnectionSyncStatus.SYNCED_WITH_WARNINGS ? ( + Synced with warnings + ) : status === ConnectionSyncStatus.FAILED ? ( + <> Sync failed + ) : null} + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx new file mode 100644 index 00000000..df60ca38 --- /dev/null +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx @@ -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([]); + 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
+ {`Error loading repositories. Reason: ${reposError.message}`} +
+ } + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ + setSelectedStatuses(value)} + defaultValue={[]} + placeholder="Filter by status" + maxCount={2} + animation={0} + /> + + {failedRepos.length > 0 && ( + + )} +
+ + {isReposPending ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : (!filteredRepos || filteredRepos.length === 0) ? ( +
+

No Repositories Found

+

+ { + searchQuery.length > 0 ? ( + No repositories found matching your filters. + ) : (!isConnectionError && !isConnectionPending && (connection.syncStatus === ConnectionSyncStatus.IN_SYNC_QUEUE || connection.syncStatus === ConnectionSyncStatus.SYNCING || connection.syncStatus === ConnectionSyncStatus.SYNC_NEEDED)) ? ( + Repositories are being synced. Please check back soon. + ) : ( + + )} +

+
+ ) : ( +
+ {filteredRepos?.map((repo) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoListItemSkeleton.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoListItemSkeleton.tsx new file mode 100644 index 00000000..2eab9a38 --- /dev/null +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoListItemSkeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export const RepoListItemSkeleton = () => { + return ( +
+
+ + +
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoRetryIndexButton.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoRetryIndexButton.tsx index 3a52bb46..24d0484f 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/repoRetryIndexButton.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoRetryIndexButton.tsx @@ -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 ( - - ); + return ( + + ); }; diff --git a/packages/web/src/app/[domain]/connections/[id]/components/retryAllFailedReposButton.tsx b/packages/web/src/app/[domain]/connections/[id]/components/retryAllFailedReposButton.tsx deleted file mode 100644 index d3b14b96..00000000 --- a/packages/web/src/app/[domain]/connections/[id]/components/retryAllFailedReposButton.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/packages/web/src/app/[domain]/connections/[id]/components/retrySyncButton.tsx b/packages/web/src/app/[domain]/connections/[id]/components/retrySyncButton.tsx deleted file mode 100644 index 85991869..00000000 --- a/packages/web/src/app/[domain]/connections/[id]/components/retrySyncButton.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx index d68aec87..a68e40f6 100644 --- a/packages/web/src/app/[domain]/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -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"; - -export default function ConnectionManagementPage() { - const params = useParams() - const searchParams = useSearchParams() - const router = useRouter() - const [org, setOrg] = useState(null) - const [connection, setConnection] = useState(null) - const [linkedRepos, setLinkedRepos] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const captureEvent = useCaptureEvent(); - - const handleSecretsNavigation = () => { - captureEvent('wa_connection_secrets_navigation_pressed', {}); - router.push(`/${params.domain}/secrets`) +import { RepoList } from "./components/repoList" +import { auth } from "@/auth" +import { getConnectionByDomain } from "@/data/connection" +import { Overview } from "./components/overview" + +interface ConnectionManagementPageProps { + params: { + domain: string + id: string + }, + searchParams: { + tab: string } +} - 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
Loading...
+export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) { + const session = await auth(); + if (!session) { + return null; } - if (error || !org || !connection) { - return + const connection = await getConnectionByDomain(Number(params.id), params.domain); + if (!connection) { + return } - const currentTab = searchParams.get("tab") || "overview" + const currentTab = searchParams.tab || "overview"; return ( @@ -154,75 +71,17 @@ export default function ConnectionManagementPage() { -

Overview

-
-
-
-

Connection Type

-

{connection.connectionType}

-
-
-

Last Synced At

-

- {connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : "never"} -

-
-
-

Linked Repositories

-

{linkedRepos.length}

-
-
-

Status

-
- {connection.syncStatus === "FAILED" ? ( - - captureEvent('wa_connection_failed_status_hover', {})}> -
- - {connection.syncStatus} - -
-
- - - -
- ) : ( - - {connection.syncStatus} - - )} - {connection.syncStatus === "FAILED" && ( - - )} -
-
-
- +
+

Overview

+
-
-

Linked Repositories

- + +
+

Linked Repositories

+
- -
- {linkedRepos.map((repo) => ( - - ))} -
-
{ 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]); diff --git a/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx b/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx index 43324546..ef3502ae 100644 --- a/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx +++ b/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx @@ -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(null); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedStatuses, setSelectedStatuses] = useState([]); - 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
+

Error loading connections: {error.message}

+
+ } return (
- {loading ? ( -
-

Loading connections...

+
+
+ + setSearchQuery(e.target.value)} + />
- ) : error ? ( -
-

Error loading connections: {error}

+ + setSelectedStatuses(value)} + defaultValue={[]} + placeholder="Filter by status" + maxCount={2} + animation={0} + /> + +
+ + {isPending ? ( + // Skeleton for loading state +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))}
) : 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 = ({
)}
- ) + ); } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/connections/components/statusIcon.tsx b/packages/web/src/app/[domain]/connections/components/statusIcon.tsx index 9ad68d75..4aad8eb6 100644 --- a/packages/web/src/app/[domain]/connections/components/statusIcon.tsx +++ b/packages/web/src/app/[domain]/connections/components/statusIcon.tsx @@ -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 ; case 'failed': return ; - + case 'succeeded-with-warnings': + default: + return null; } }, [className, status]); diff --git a/packages/web/src/components/ui/badge.tsx b/packages/web/src/components/ui/badge.tsx new file mode 100644 index 00000000..f000e3ef --- /dev/null +++ b/packages/web/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/packages/web/src/components/ui/multi-select.tsx b/packages/web/src/components/ui/multi-select.tsx new file mode 100644 index 00000000..a1737b13 --- /dev/null +++ b/packages/web/src/components/ui/multi-select.tsx @@ -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, + VariantProps { + /** + * 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(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + + const handleInputKeyDown = ( + event: React.KeyboardEvent + ) => { + 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 ( + + + + + setIsPopoverOpen(false)} + > + + + + No results found. + + + {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + className="cursor-pointer" + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+ + +
+ {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full" + > + Close + +
+
+
+
+
+ {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
+ ); + } +); + +MultiSelect.displayName = "MultiSelect"; \ No newline at end of file diff --git a/packages/web/src/data/connection.ts b/packages/web/src/data/connection.ts index b49d8e09..7664dcd9 100644 --- a/packages/web/src/data/connection.ts +++ b/packages/web/src/data/connection.ts @@ -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; -} \ No newline at end of file + return connection; +} diff --git a/packages/web/src/data/org.ts b/packages/web/src/data/org.ts index dd1a4643..2d57fbc8 100644 --- a/packages/web/src/data/org.ts +++ b/packages/web/src/data/org.ts @@ -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({ diff --git a/packages/web/src/lib/environment.client.ts b/packages/web/src/lib/environment.client.ts index 1c5e9ea3..f58203da 100644 --- a/packages/web/src/lib/environment.client.ts +++ b/packages/web/src/lib/environment.client.ts @@ -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); \ No newline at end of file +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); \ No newline at end of file diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index fa625a49..553745bc 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -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: {}, diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 58322d3a..7d016aa8 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -244,3 +244,20 @@ export const measure = async (cb: () => Promise, 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 (promise: Promise): Promise => { + const data = await promise; + if (isServiceError(data)) { + throw new Error(data.message); + } + + return data; +} \ No newline at end of file