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 { 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 { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import os from 'os'; import os from 'os';
@ -24,7 +24,7 @@ type JobPayload = {
}; };
type JobResult = { type JobResult = {
repoCount: number repoCount: number,
} }
export class ConnectionManager implements IConnectionManager { export class ConnectionManager implements IConnectionManager {
@ -82,7 +82,7 @@ export class ConnectionManager implements IConnectionManager {
}, this.settings.resyncConnectionPollingIntervalMs); }, this.settings.resyncConnectionPollingIntervalMs);
} }
private async runSyncJob(job: Job<JobPayload>) { private async runSyncJob(job: Job<JobPayload>): Promise<JobResult> {
const { config, orgId } = job.data; const { config, orgId } = job.data;
// @note: We aren't actually doing anything with this atm. // @note: We aren't actually doing anything with this atm.
const abortController = new AbortController(); const abortController = new AbortController();
@ -105,6 +105,7 @@ export class ConnectionManager implements IConnectionManager {
id: job.data.connectionId, id: job.data.connectionId,
}, },
data: { data: {
syncStatus: ConnectionSyncStatus.SYNCING,
syncStatusMetadata: {} syncStatusMetadata: {}
} }
}) })
@ -233,12 +234,25 @@ export class ConnectionManager implements IConnectionManager {
this.logger.info(`Connection sync job ${job.id} completed`); this.logger.info(`Connection sync job ${job.id} completed`);
const { connectionId } = job.data; 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({ await this.db.connection.update({
where: { where: {
id: connectionId, id: connectionId,
}, },
data: { 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() syncedAt: new Date()
} }
}) })

View file

@ -82,7 +82,7 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
const tagGlobs = config.revisions.tags; const tagGlobs = config.revisions.tags;
allRepos = await Promise.all( allRepos = await Promise.all(
allRepos.map(async (allRepos) => { allRepos.map(async (allRepos) => {
const [owner, name] = allRepos.name!.split('/'); const [owner, name] = allRepos.full_name!.split('/');
let tags = (await fetchWithRetry( let tags = (await fetchWithRetry(
() => getTagsForRepo(owner, name, api), () => getTagsForRepo(owner, name, api),
`tags for ${owner}/${name}`, `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) => { export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => {
const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
const token = tokenResult?.token ?? FALLBACK_GITLAB_TOKEN; const token = tokenResult?.token ?? FALLBACK_GITLAB_TOKEN;
const secretKey = tokenResult?.secretKey;
const api = new Gitlab({ const api = new Gitlab({
...(token ? { ...(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 IN_SYNC_QUEUE
SYNCING SYNCING
SYNCED SYNCED
SYNCED_WITH_WARNINGS
FAILED FAILED
} }

View file

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

View file

@ -13,8 +13,8 @@ import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto" import { encrypt } from "@sourcebot/crypto"
import { getConnection, getLinkedRepos } from "./data/connection"; import { getConnection } from "./data/connection";
import { ConnectionSyncStatus, Prisma, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { headers } from "next/headers" import { headers } from "next/headers"
import { getStripe } from "@/lib/stripe" import { getStripe } from "@/lib/stripe"
import { getUser } from "@/data/user"; 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< export const getConnections = async (domain: string, filter: { status?: ConnectionSyncStatus[] } = {}) =>
{
id: number,
name: string,
syncStatus: ConnectionSyncStatus,
syncStatusMetadata: Prisma.JsonValue,
connectionType: string,
updatedAt: Date,
syncedAt?: Date
}[] | ServiceError
> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
const connections = await prisma.connection.findMany({ const connections = await prisma.connection.findMany({
where: { where: {
orgId, orgId,
...(filter.status ? {
syncStatus: { in: filter.status }
} : {}),
}, },
include: {
repos: {
include: {
repo: true,
}
}
}
}); });
return connections.map((connection) => ({ return connections.map((connection) => ({
@ -278,45 +278,78 @@ export const getConnections = async (domain: string): Promise<
connectionType: connection.connectionType, connectionType: connection.connectionType,
updatedAt: connection.updatedAt, updatedAt: connection.updatedAt,
syncedAt: connection.syncedAt ?? undefined, 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) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { 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) { if (!connection) {
return notFound(); return notFound();
} }
const linkedRepos = await getLinkedRepos(connectionId, orgId); return {
id: connection.id,
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({ name: connection.name,
repoId: repo.repo.id, syncStatus: connection.syncStatus,
repoName: repo.repo.name, 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) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId); const repos = await prisma.repo.findMany({
if (!connection) { where: {
return notFound(); orgId,
} ...(filter.status ? {
repoIndexingStatus: { in: filter.status }
} : {}),
...(filter.connectionId ? {
connections: {
some: {
connectionId: filter.connectionId
}
}
} : {}),
},
include: {
connections: true,
}
});
const linkedRepos = await getLinkedRepos(connectionId, orgId); return repos.map((repo) => ({
repoId: repo.id,
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repo.repoIndexingStatus === RepoIndexingStatus.INDEXING).map((repo) => ({ repoName: repo.name,
repoId: repo.repo.id, linkedConnections: repo.connections.map((connection) => connection.connectionId),
repoName: repo.repo.name, 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> => export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { 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> => export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { 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) => withAuth((session) =>
withOrgMembership(session, domain, async () => { withOrgMembership(session, domain, async ({ orgId }) => {
const repo = await prisma.repo.findUnique({ await prisma.repo.updateMany({
where: { where: {
id: repoId, id: { in: repoIds },
}, orgId,
});
if (!repo) {
return notFound();
}
await prisma.repo.update({
where: {
id: repoId,
}, },
data: { data: {
repoIndexingStatus: RepoIndexingStatus.NEW, 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> => export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
@ -654,7 +641,6 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* minRequiredRole = */ OrgRole.OWNER)
); );
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async (session) => { withAuth(async (session) => {
const invite = await prisma.invite.findUnique({ const invite = await prisma.invite.findUnique({
@ -828,97 +814,6 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* 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) => export const createOnboardingStripeCheckoutSession = async (domain: string) =>
withAuth(async (session) => withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
@ -1071,8 +966,6 @@ export const createStripeCheckoutSession = async (domain: string) =>
}) })
) )
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> => export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { 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> => export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
withAuth(async (session) => withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
@ -1176,16 +1043,6 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* 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> => export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> =>
withAuth(async () => { withAuth(async () => {
const org = await prisma.org.findFirst({ const org = await prisma.org.findFirst({
@ -1357,7 +1214,6 @@ export const getOrgMembers = async (domain: string) =>
}) })
); );
export const getOrgInvites = async (domain: string) => export const getOrgInvites = async (domain: string) =>
withAuth(async (session) => withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => { 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, CommandList,
} from "@/components/ui/command" } from "@/components/ui/command"
import { Button } from "@/components/ui/button"; 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 { ChevronsUpDown, Check, PlusCircleIcon, Loader2, Eye, EyeOff, TriangleAlert } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@ -49,9 +49,9 @@ export const SecretCombobox = ({
const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false); const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const { data: secrets, isLoading, refetch } = useQuery({ const { data: secrets, isPending, isError, refetch } = useQuery({
queryKey: ["secrets"], queryKey: ["secrets"],
queryFn: () => getSecrets(domain), queryFn: () => unwrapServiceError(getSecrets(domain)),
}); });
const onSecretCreated = useCallback((key: string) => { const onSecretCreated = useCallback((key: string) => {
@ -59,16 +59,6 @@ export const SecretCombobox = ({
refetch(); refetch();
}, [onSecretChange, 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 ( return (
<> <>
<Popover> <Popover>
@ -83,7 +73,7 @@ export const SecretCombobox = ({
)} )}
disabled={isDisabled} disabled={isDisabled}
> >
{isSecretNotFoundWarningVisible && ( {!(isPending || isError) && isDefined(secretKey) && !secrets.some(({ key }) => key === secretKey) && (
<TooltipProvider> <TooltipProvider>
<Tooltip <Tooltip
@ -105,12 +95,13 @@ export const SecretCombobox = ({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0.5"> <PopoverContent className="p-0.5">
{isLoading && ( {isPending ? (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
</div> </div>
)} ) : isError ? (
{secrets && !isServiceError(secrets) && secrets.length > 0 && ( <p className="p-2 text-sm text-destructive">Failed to load secrets</p>
) : secrets.length > 0 && (
<> <>
<Command className="mb-2"> <Command className="mb-2">
<CommandInput <CommandInput

View file

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

View file

@ -1,73 +1,41 @@
"use client"; "use client";
import Link from "next/link"; import { getRepos } from "@/actions";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; import { HoverCard, HoverCardContent, HoverCardTrigger } 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 useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
interface InProgress { import { useDomain } from "@/hooks/useDomain";
connectionId: number; import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
repoId: number; import { unwrapServiceError } from "@/lib/utils";
repoName: string; import { RepoIndexingStatus } from "@prisma/client";
} import { useQuery } from "@tanstack/react-query";
import { Loader2Icon } from "lucide-react";
import Link from "next/link";
export const ProgressNavIndicator = () => { export const ProgressNavIndicator = () => {
const domain = useDomain(); const domain = useDomain();
const [inProgressJobs, setInProgressJobs] = useState<InProgress[]>([]);
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
useEffect(() => { const { data: inProgressRepos, isPending, isError } = useQuery({
const fetchInProgressJobs = async () => { queryKey: ['repos', domain],
const connections = await getConnections(domain); queryFn: () => unwrapServiceError(getRepos(domain)),
if (!isServiceError(connections)) { select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
const allInProgressRepos: InProgress[] = []; refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
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(); if (isPending || isError || inProgressRepos.length === 0) {
}, [domain, captureEvent]);
if (inProgressJobs.length === 0) {
return null; return null;
} }
return ( return (
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_progress_nav_pressed', {})}> <Link
href={`/${domain}/connections`}
onClick={() => captureEvent('wa_progress_nav_pressed', {})}
>
<HoverCard openDelay={50}> <HoverCard openDelay={50}>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}> <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"> <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" /> <Loader2Icon className="h-4 w-4 animate-spin" />
<span>{inProgressJobs.length}</span> <span>{inProgressRepos.length}</span>
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-80 border border-green-200 dark:border-green-800 rounded-lg"> <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: The following repositories are currently being indexed:
</p> </p>
<div className="flex flex-col gap-2 pl-4"> <div className="flex flex-col gap-2 pl-4">
{inProgressJobs.slice(0, 10).map(item => ( {inProgressRepos.slice(0, 10).map(item => (
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`} onClick={() => captureEvent('wa_progress_nav_job_pressed', {})}> // 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 <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 rounded-md text-sm text-green-700 dark:text-green-300
border border-green-200/50 dark:border-green-800/50 border border-green-200/50 dark:border-green-800/50
@ -90,9 +59,9 @@ export const ProgressNavIndicator = () => {
</div> </div>
</Link> </Link>
))} ))}
{inProgressJobs.length > 10 && ( {inProgressRepos.length > 10 && (
<div className="text-sm text-green-600/90 dark:text-green-300/90 pl-3 pt-1"> <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>
)} )}
</div> </div>

View file

@ -5,56 +5,24 @@ import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/h
import { AlertTriangleIcon } from "lucide-react"; import { AlertTriangleIcon } from "lucide-react";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { getConnections } from "@/actions"; import { getConnections } from "@/actions";
import { useState } from "react"; import { unwrapServiceError } from "@/lib/utils";
import { useEffect } from "react";
import { isServiceError } from "@/lib/utils";
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
interface Warning { import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
connectionId?: number; import { useQuery } from "@tanstack/react-query";
connectionName?: string; import { ConnectionSyncStatus } from "@prisma/client";
}
export const WarningNavIndicator = () => { export const WarningNavIndicator = () => {
const domain = useDomain(); const domain = useDomain();
const [warnings, setWarnings] = useState<Warning[]>([]);
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
useEffect(() => { const { data: connections, isPending, isError } = useQuery({
const fetchWarnings = async () => { queryKey: ['connections', domain],
const connections = await getConnections(domain); queryFn: () => unwrapServiceError(getConnections(domain)),
const warnings: Warning[] = []; select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.SYNCED_WITH_WARNINGS),
if (!isServiceError(connections)) { refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
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,
});
}
setWarnings(prevWarnings => { if (isPending || isError || connections.length === 0) {
// 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) {
return null; return null;
} }
@ -64,7 +32,7 @@ export const WarningNavIndicator = () => {
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}> <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"> <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" /> <AlertTriangleIcon className="h-4 w-4" />
<span>{warnings.length}</span> <span>{connections.length}</span>
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg"> <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: The following connections have references that could not be found:
</p> </p>
<div className="flex flex-col gap-2 pl-4"> <div className="flex flex-col gap-2 pl-4">
{warnings.slice(0, 10).map(warning => ( {connections.slice(0, 10).map(connection => (
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}> <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 <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 rounded-md text-sm text-yellow-700 dark:text-yellow-300
border border-yellow-200/50 dark:border-yellow-800/50 border border-yellow-200/50 dark:border-yellow-800/50
hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors"> 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> </div>
</Link> </Link>
))} ))}
{warnings.length > 10 && ( {connections.length > 10 && (
<div className="text-sm text-yellow-600/90 dark:text-yellow-300/90 pl-3 pt-1"> <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>
)} )}
</div> </div>

View file

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

View file

@ -1,68 +1,80 @@
'use client';
import { AlertTriangle } from "lucide-react" import { AlertTriangle } from "lucide-react"
import { Prisma } from "@sourcebot/db" import { Prisma, ConnectionSyncStatus } from "@sourcebot/db"
import { RetrySyncButton } from "./retrySyncButton"
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema" import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema"
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { ReloadIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
interface NotFoundWarningProps { interface NotFoundWarningProps {
syncStatusMetadata: Prisma.JsonValue syncStatus: ConnectionSyncStatus
onSecretsClick: () => void syncStatusMetadata: Prisma.JsonValue
connectionId: number onSecretsClick: () => void
domain: string connectionType: string
connectionType: string onRetrySync: () => void
} }
export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connectionId, domain, connectionType }: NotFoundWarningProps) => { export const NotFoundWarning = ({ syncStatus, syncStatusMetadata, onSecretsClick, connectionType, onRetrySync }: NotFoundWarningProps) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata); const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
if (!parseResult.success || !parseResult.data.notFound) { if (syncStatus !== ConnectionSyncStatus.SYNCED_WITH_WARNINGS || !parseResult.success || !parseResult.data.notFound) {
return null; return null;
} }
const { notFound } = parseResult.data; const { notFound } = parseResult.data;
if (notFound.users.length === 0 && notFound.orgs.length === 0 && notFound.repos.length === 0) { if (notFound.users.length === 0 && notFound.orgs.length === 0 && notFound.repos.length === 0) {
return null; return null;
} else { } else {
captureEvent('wa_connection_not_found_warning_displayed', {}); captureEvent('wa_connection_not_found_warning_displayed', {});
} }
return ( 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 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"> <div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 flex-shrink-0" /> <AlertTriangle className="h-5 w-5 flex-shrink-0" />
<h3 className="font-semibold">Unable to fetch all references</h3> <h3 className="font-semibold">Unable to fetch all references</h3>
</div> </div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed"> <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{" "} 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"> <button onClick={onSecretsClick} className="text-yellow-500 dark:text-yellow-400 font-bold hover:underline">
valid token valid token
</button>{" "} </button>{" "}
to access them if they&apos;re private. to access them if they&apos;re private.
</p> </p>
<ul className="w-full space-y-2 text-sm"> <ul className="w-full space-y-2 text-sm">
{notFound.users.length > 0 && ( {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"> <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="font-medium">Users:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.users.join(', ')}</span> <span className="text-yellow-600 dark:text-yellow-300">{notFound.users.join(', ')}</span>
</li> </li>
)} )}
{notFound.orgs.length > 0 && ( {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"> <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="font-medium">{connectionType === "gitlab" ? "Groups" : "Organizations"}:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.orgs.join(', ')}</span> <span className="text-yellow-600 dark:text-yellow-300">{notFound.orgs.join(', ')}</span>
</li> </li>
)} )}
{notFound.repos.length > 0 && ( {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"> <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="font-medium">{connectionType === "gitlab" ? "Projects" : "Repositories"}:</span>
<span className="text-yellow-600 dark:text-yellow-300">{notFound.repos.join(', ')}</span> <span className="text-yellow-600 dark:text-yellow-300">{notFound.repos.join(', ')}</span>
</li> </li>
)} )}
</ul> </ul>
<div className="w-full flex justify-center"> <div className="w-full flex justify-center">
<RetrySyncButton connectionId={connectionId} domain={domain} /> <Button
</div> variant="outline"
</div> 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 { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons" import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast"; import { toast } from "@/components/hooks/use-toast";
import { flagRepoForIndex } from "@/actions"; import { flagReposForIndex } from "@/actions";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetryRepoIndexButtonProps { interface RetryRepoIndexButtonProps {
repoId: number; repoId: number;
domain: string; domain: string;
} }
export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => { export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
return ( return (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="ml-2" className="ml-2"
onClick={async () => { onClick={async () => {
const result = await flagRepoForIndex(repoId, domain); const result = await flagReposForIndex([repoId], domain);
if (isServiceError(result)) { if (isServiceError(result)) {
toast({ toast({
description: `❌ Failed to flag repository for indexing.`, description: `❌ Failed to flag repository for indexing.`,
}); });
captureEvent('wa_repo_retry_index_fail', { captureEvent('wa_repo_retry_index_fail', {
error: result.errorCode, error: result.errorCode,
}); });
} else { } else {
toast({ toast({
description: "✅ Repository flagged for indexing.", description: "✅ Repository flagged for indexing.",
}); });
captureEvent('wa_repo_retry_index_success', {}); captureEvent('wa_repo_retry_index_success', {});
} }
}} }}
> >
<ReloadIcon className="h-4 w-4 mr-2" /> <ReloadIcon className="h-4 w-4 mr-2" />
Retry Index Retry Index
</Button> </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 { NotFound } from "@/app/[domain]/components/notFound"
import { import {
Breadcrumb, Breadcrumb,
@ -9,7 +7,6 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb" } from "@/components/ui/breadcrumb"
import { ScrollArea } from "@/components/ui/scroll-area"
import { TabSwitcher } from "@/components/ui/tab-switcher" import { TabSwitcher } from "@/components/ui/tab-switcher"
import { Tabs, TabsContent } from "@/components/ui/tabs" import { Tabs, TabsContent } from "@/components/ui/tabs"
import { ConnectionIcon } from "../components/connectionIcon" import { ConnectionIcon } from "../components/connectionIcon"
@ -17,113 +14,33 @@ import { Header } from "../../components/header"
import { ConfigSetting } from "./components/configSetting" import { ConfigSetting } from "./components/configSetting"
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting" import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
import { DisplayNameSetting } from "./components/displayNameSetting" import { DisplayNameSetting } from "./components/displayNameSetting"
import { RepoListItem } from "./components/repoListItem" import { RepoList } from "./components/repoList"
import { useParams, useSearchParams, useRouter } from "next/navigation" import { auth } from "@/auth"
import { useEffect, useState } from "react" import { getConnectionByDomain } from "@/data/connection"
import type { Connection, Repo, Org } from "@sourcebot/db" import { Overview } from "./components/overview"
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() { interface ConnectionManagementPageProps {
const params = useParams() params: {
const searchParams = useSearchParams() domain: string
const router = useRouter() id: string
const [org, setOrg] = useState<Org | null>(null) },
const [connection, setConnection] = useState<Connection | null>(null) searchParams: {
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([]) tab: string
const [loading, setLoading] = useState(true) }
const [error, setError] = useState<string | null>(null) }
const captureEvent = useCaptureEvent();
const handleSecretsNavigation = () => { export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) {
captureEvent('wa_connection_secrets_navigation_pressed', {}); const session = await auth();
router.push(`/${params.domain}/secrets`) if (!session) {
return null;
} }
useEffect(() => { const connection = await getConnectionByDomain(Number(params.id), params.domain);
const loadData = async () => { if (!connection) {
try { return <NotFound className="flex w-full h-full items-center justify-center" message="Connection not found" />
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>
} }
if (error || !org || !connection) { const currentTab = searchParams.tab || "overview";
return <NotFound className="flex w-full h-full items-center justify-center" message={error || "Not found"} />
}
const currentTab = searchParams.get("tab") || "overview"
return ( return (
<Tabs value={currentTab} className="w-full"> <Tabs value={currentTab} className="w-full">
@ -154,75 +71,17 @@ export default function ConnectionManagementPage() {
</Header> </Header>
<TabsContent <TabsContent
value="overview" value="overview"
className="space-y-8"
> >
<h1 className="font-semibold text-lg">Overview</h1> <div>
<div className="mt-4 flex flex-col gap-4"> <h1 className="font-semibold text-lg mb-4">Overview</h1>
<div className="grid grid-cols-2 gap-4"> <Overview connectionId={connection.id} />
<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> </div>
<div className="flex justify-between items-center mt-8">
<h1 className="font-semibold text-lg">Linked Repositories</h1> <div>
<RetryAllFailedReposButton connectionId={connection.id} domain={params.domain as string} /> <h1 className="font-semibold text-lg mb-4">Linked Repositories</h1>
<RepoList connectionId={connection.id} />
</div> </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>
<TabsContent <TabsContent
value="settings" value="settings"

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { CircleCheckIcon, CircleXIcon } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { FiLoader } from "react-icons/fi"; 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 = ({ export const StatusIcon = ({
status, status,
@ -19,7 +19,9 @@ export const StatusIcon = ({
return <CircleCheckIcon className={cn('text-green-600', className)} />; return <CircleCheckIcon className={cn('text-green-600', className)} />;
case 'failed': case 'failed':
return <CircleXIcon className={cn('text-destructive', className)} />; return <CircleXIcon className={cn('text-destructive', className)} />;
case 'succeeded-with-warnings':
default:
return null;
} }
}, [className, status]); }, [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; return connection;
} }
export const getLinkedRepos = async (connectionId: number, orgId: number) => { export const getConnectionByDomain = async (connectionId: number, domain: string) => {
const linkedRepos = await prisma.repoToConnection.findMany({ const connection = await prisma.connection.findUnique({
where: { where: {
connection: { id: connectionId,
id: connectionId, org: {
orgId: orgId, domain: domain,
} }
}, },
include: {
repo: true,
}
}); });
return linkedRepos; return connection;
} }

View file

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

View file

@ -1,6 +1,6 @@
import 'client-only'; 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_PAPIK = getEnv(process.env.NEXT_PUBLIC_POSTHOG_PAPIK);
export const NEXT_PUBLIC_POSTHOG_HOST = getEnv(process.env.NEXT_PUBLIC_POSTHOG_HOST); 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_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_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_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: { wa_progress_nav_connection_fetch_fail: {
error: string, error: string,
}, },
wa_progress_nav_job_fetch_fail: { wa_progress_nav_repo_fetch_fail: {
error: string, error: string,
}, },
wa_progress_nav_hover: {}, wa_progress_nav_hover: {},
@ -205,13 +205,8 @@ export type PosthogEventMap = {
wa_connection_retry_all_failed_repos_fetch_fail: { wa_connection_retry_all_failed_repos_fetch_fail: {
error: string, error: string,
}, },
wa_connection_retry_all_failed_repos_fail: { wa_connection_retry_all_failed_repos_fail: {},
successCount: number, wa_connection_retry_all_failed_repos_success: {},
failureCount: number,
},
wa_connection_retry_all_failed_repos_success: {
successCount: number,
},
wa_connection_retry_all_failed_no_repos: {}, wa_connection_retry_all_failed_no_repos: {},
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
wa_repo_retry_index_success: {}, wa_repo_retry_index_success: {},

View file

@ -244,3 +244,20 @@ export const measure = async <T>(cb: () => Promise<T>, measureName: string) => {
durationMs 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;
}