diff --git a/packages/backend/src/azuredevops.ts b/packages/backend/src/azuredevops.ts index 1f9c089b..de587935 100644 --- a/packages/backend/src/azuredevops.ts +++ b/packages/backend/src/azuredevops.ts @@ -1,6 +1,6 @@ import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type"; import { createLogger } from "@sourcebot/logger"; -import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; +import { measure, fetchWithRetry } from "./utils.js"; import micromatch from "micromatch"; import { PrismaClient } from "@sourcebot/db"; import { BackendException, BackendError } from "@sourcebot/error"; @@ -8,6 +8,7 @@ import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import * as Sentry from "@sentry/node"; import * as azdev from "azure-devops-node-api"; import { GitRepository } from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import { getTokenFromConfig } from "@sourcebot/crypto"; const logger = createLogger('azuredevops'); const AZUREDEVOPS_CLOUD_HOSTNAME = "dev.azure.com"; @@ -34,7 +35,7 @@ export const getAzureDevOpsReposFromConfig = async ( const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`; const token = config.token ? - await getTokenFromConfig(config.token, orgId, db, logger) : + await getTokenFromConfig(config.token, orgId, db) : undefined; if (!token) { diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index ae4d6b40..75adc311 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -4,7 +4,7 @@ import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type" import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch"; import { createLogger } from "@sourcebot/logger"; import { PrismaClient } from "@sourcebot/db"; -import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; +import { measure, fetchWithRetry } from "./utils.js"; import * as Sentry from "@sentry/node"; import { SchemaRepository as CloudRepository, @@ -12,6 +12,7 @@ import { import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { processPromiseResults } from "./connectionUtils.js"; import { throwIfAnyFailed } from "./connectionUtils.js"; +import { getTokenFromConfig } from "@sourcebot/crypto"; const logger = createLogger('bitbucket'); const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org'; @@ -59,7 +60,7 @@ type ServerPaginatedResponse = { export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => { const token = config.token ? - await getTokenFromConfig(config.token, orgId, db, logger) : + await getTokenFromConfig(config.token, orgId, db) : undefined; if (config.deploymentType === 'server' && !config.url) { diff --git a/packages/backend/src/ee/githubAppManager.ts b/packages/backend/src/ee/githubAppManager.ts index 2518c27b..ad4aa247 100644 --- a/packages/backend/src/ee/githubAppManager.ts +++ b/packages/backend/src/ee/githubAppManager.ts @@ -1,8 +1,7 @@ -import { GithubAppConfig, SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; import { loadConfig } from "@sourcebot/shared"; import { env } from "../env.js"; import { createLogger } from "@sourcebot/logger"; -import { getTokenFromConfig } from "../utils.js"; +import { getTokenFromConfig } from "@sourcebot/crypto"; import { PrismaClient } from "@sourcebot/db"; import { App } from "@octokit/app"; import { GitHubAppConfig } from "@sourcebot/schemas/v3/index.type"; @@ -54,7 +53,7 @@ export class GithubAppManager { return; } - const githubApps = config.apps.filter(app => app.type === 'githubApp') as GithubAppConfig[]; + const githubApps = config.apps.filter(app => app.type === 'githubApp') as GitHubAppConfig[]; logger.info(`Found ${githubApps.length} GitHub apps in config`); for (const app of githubApps) { @@ -63,7 +62,7 @@ export class GithubAppManager { // @todo: we should move SINGLE_TENANT_ORG_ID to shared package or just remove the need to pass this in // when resolving tokens const SINGLE_TENANT_ORG_ID = 1; - const privateKey = await getTokenFromConfig(app.privateKey, SINGLE_TENANT_ORG_ID, this.db!); + const privateKey = await getTokenFromConfig(app.privateKey, SINGLE_TENANT_ORG_ID, this.db); const octokitApp = new App({ appId: Number(app.id), diff --git a/packages/backend/src/gitea.ts b/packages/backend/src/gitea.ts index 5dca28d3..ab3eee3f 100644 --- a/packages/backend/src/gitea.ts +++ b/packages/backend/src/gitea.ts @@ -1,6 +1,6 @@ import { Api, giteaApi, HttpResponse, Repository as GiteaRepository } from 'gitea-js'; import { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type'; -import { getTokenFromConfig, measure } from './utils.js'; +import { measure } from './utils.js'; import fetch from 'cross-fetch'; import { createLogger } from '@sourcebot/logger'; import micromatch from 'micromatch'; @@ -8,6 +8,7 @@ import { PrismaClient } from '@sourcebot/db'; import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js'; import * as Sentry from "@sentry/node"; import { env } from './env.js'; +import { getTokenFromConfig } from "@sourcebot/crypto"; const logger = createLogger('gitea'); const GITEA_CLOUD_HOSTNAME = "gitea.com"; @@ -18,7 +19,7 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org GITEA_CLOUD_HOSTNAME; const token = config.token ? - await getTokenFromConfig(config.token, orgId, db, logger) : + await getTokenFromConfig(config.token, orgId, db) : hostname === GITEA_CLOUD_HOSTNAME ? env.FALLBACK_GITEA_CLOUD_TOKEN : undefined; diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index 464bb7b1..550d259d 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -8,7 +8,8 @@ import micromatch from "micromatch"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import { GithubAppManager } from "./ee/githubAppManager.js"; import { env } from "./env.js"; -import { fetchWithRetry, getTokenFromConfig, measure } from "./utils.js"; +import { fetchWithRetry, measure } from "./utils.js"; +import { getTokenFromConfig } from "@sourcebot/crypto"; export const GITHUB_CLOUD_HOSTNAME = "github.com"; const logger = createLogger('github'); @@ -97,7 +98,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o GITHUB_CLOUD_HOSTNAME; const token = config.token ? - await getTokenFromConfig(config.token, orgId, db, logger) : + await getTokenFromConfig(config.token, orgId, db) : hostname === GITHUB_CLOUD_HOSTNAME ? env.FALLBACK_GITHUB_CLOUD_TOKEN : undefined; diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index de52e64d..e4954b34 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -2,11 +2,12 @@ import { Gitlab, ProjectSchema } from "@gitbeaker/rest"; import micromatch from "micromatch"; import { createLogger } from "@sourcebot/logger"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type" -import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; +import { measure, fetchWithRetry } from "./utils.js"; import { PrismaClient } from "@sourcebot/db"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import * as Sentry from "@sentry/node"; import { env } from "./env.js"; +import { getTokenFromConfig } from "@sourcebot/crypto"; const logger = createLogger('gitlab'); export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; @@ -17,7 +18,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o GITLAB_CLOUD_HOSTNAME; const token = config.token ? - await getTokenFromConfig(config.token, orgId, db, logger) : + await getTokenFromConfig(config.token, orgId, db) : hostname === GITLAB_CLOUD_HOSTNAME ? env.FALLBACK_GITLAB_CLOUD_TOKEN : undefined; diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 8a1d0cc6..4bb18549 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -2,8 +2,7 @@ import { Logger } from "winston"; import { RepoAuthCredentials, RepoWithConnections } from "./types.js"; import path from 'path'; import { PrismaClient, Repo } from "@sourcebot/db"; -import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto"; -import { BackendException, BackendError } from "@sourcebot/error"; +import { getTokenFromConfig } from "@sourcebot/crypto"; import * as Sentry from "@sentry/node"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { GithubAppManager } from "./ee/githubAppManager.js"; @@ -24,22 +23,6 @@ export const marshalBool = (value?: boolean) => { return !!value ? '1' : '0'; } -export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => { - try { - return await getTokenFromConfigBase(token, orgId, db); - } catch (error: unknown) { - if (error instanceof Error) { - const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, { - message: error.message, - }); - Sentry.captureException(e); - logger?.error(error.message); - throw e; - } - throw error; - } -}; - export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => { let absolutePath = localPath; if (!path.isAbsolute(absolutePath)) { @@ -156,7 +139,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P if (connection.connectionType === 'github') { const config = connection.config as unknown as GithubConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db); return { hostUrl: config.url, token, @@ -171,7 +154,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P } else if (connection.connectionType === 'gitlab') { const config = connection.config as unknown as GitlabConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db); return { hostUrl: config.url, token, @@ -187,7 +170,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P } else if (connection.connectionType === 'gitea') { const config = connection.config as unknown as GiteaConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db); return { hostUrl: config.url, token, @@ -202,7 +185,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P } else if (connection.connectionType === 'bitbucket') { const config = connection.config as unknown as BitbucketConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db); const username = config.user ?? 'x-token-auth'; return { hostUrl: config.url, @@ -219,7 +202,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P } else if (connection.connectionType === 'azuredevops') { const config = connection.config as unknown as AzureDevOpsConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db); // For ADO server, multiple auth schemes may be supported. If the ADO deployment supports NTLM, the git clone will default // to this over basic auth. As a result, we cannot embed the token in the clone URL and must force basic auth by passing in the token diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 6cacac68..7c3a472a 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -10,7 +10,7 @@ import { prisma } from "@/prisma"; import { render } from "@react-email/components"; import * as Sentry from '@sentry/nextjs'; import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto"; -import { ApiKey, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db"; +import { ApiKey, ConnectionSyncJobStatus, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; @@ -30,7 +30,7 @@ import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { ApiKeyPayload, TenancyMode } from "./lib/types"; -import { withOptionalAuthV2 } from "./withAuthV2"; +import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2"; const logger = createLogger('web-actions'); const auditService = getAuditService(); @@ -593,6 +593,7 @@ export const getReposStats = async () => sew(() => prisma.repo.count({ where: { orgId: org.id, + indexedAt: null, jobs: { some: { type: RepoIndexingJobType.INDEX, @@ -604,7 +605,6 @@ export const getReposStats = async () => sew(() => } }, }, - indexedAt: null, } }), prisma.repo.count({ @@ -625,6 +625,42 @@ export const getReposStats = async () => sew(() => }) ) +export const getConnectionStats = async () => sew(() => + withAuthV2(async ({ org, prisma }) => { + const [ + numberOfConnections, + numberOfConnectionsWithFirstTimeSyncJobsInProgress, + ] = await Promise.all([ + prisma.connection.count({ + where: { + orgId: org.id, + } + }), + prisma.connection.count({ + where: { + orgId: org.id, + syncedAt: null, + syncJobs: { + some: { + status: { + in: [ + ConnectionSyncJobStatus.PENDING, + ConnectionSyncJobStatus.IN_PROGRESS, + ] + } + } + } + } + }) + ]); + + return { + numberOfConnections, + numberOfConnectionsWithFirstTimeSyncJobsInProgress, + }; + }) +); + export const getRepoInfoByName = async (repoName: string) => sew(() => withOptionalAuthV2(async ({ org, prisma }) => { // @note: repo names are represented by their remote url diff --git a/packages/web/src/app/[domain]/components/navigationMenu/index.tsx b/packages/web/src/app/[domain]/components/navigationMenu/index.tsx index 54842731..f350fe92 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu/index.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu/index.tsx @@ -1,4 +1,4 @@ -import { getRepos, getReposStats } from "@/actions"; +import { getConnectionStats, getRepos, getReposStats } from "@/actions"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { auth } from "@/auth"; import { Button } from "@/components/ui/button"; @@ -39,6 +39,11 @@ export const NavigationMenu = async ({ throw new ServiceErrorException(repoStats); } + const connectionStats = isAuthenticated ? await getConnectionStats() : null; + if (isServiceError(connectionStats)) { + throw new ServiceErrorException(connectionStats); + } + const sampleRepos = await getRepos({ where: { jobs: { @@ -93,7 +98,12 @@ export const NavigationMenu = async ({ 0} + isSettingsButtonNotificationDotVisible={ + connectionStats ? + connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0 : + false + } isAuthenticated={isAuthenticated} /> diff --git a/packages/web/src/app/[domain]/components/navigationMenu/navigationItems.tsx b/packages/web/src/app/[domain]/components/navigationMenu/navigationItems.tsx index 52db3315..b5a31483 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu/navigationItems.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu/navigationItems.tsx @@ -3,20 +3,23 @@ import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import { Badge } from "@/components/ui/badge"; import { cn, getShortenedNumberDisplayString } from "@/lib/utils"; -import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, CircleIcon } from "lucide-react"; +import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon } from "lucide-react"; import { usePathname } from "next/navigation"; +import { NotificationDot } from "../notificationDot"; interface NavigationItemsProps { domain: string; numberOfRepos: number; - numberOfReposWithFirstTimeIndexingJobsInProgress: number; + isReposButtonNotificationDotVisible: boolean; + isSettingsButtonNotificationDotVisible: boolean; isAuthenticated: boolean; } export const NavigationItems = ({ domain, numberOfRepos, - numberOfReposWithFirstTimeIndexingJobsInProgress, + isReposButtonNotificationDotVisible, + isSettingsButtonNotificationDotVisible, isAuthenticated, }: NavigationItemsProps) => { const pathname = usePathname(); @@ -59,9 +62,7 @@ export const NavigationItems = ({ Repositories {getShortenedNumberDisplayString(numberOfRepos)} - {numberOfReposWithFirstTimeIndexingJobsInProgress > 0 && ( - - )} + {isReposButtonNotificationDotVisible && } {isActive(`/${domain}/repos`) && } @@ -74,6 +75,7 @@ export const NavigationItems = ({ > Settings + {isSettingsButtonNotificationDotVisible && } {isActive(`/${domain}/settings`) && } @@ -86,4 +88,4 @@ const ActiveIndicator = () => { return (
); -}; \ No newline at end of file +}; diff --git a/packages/web/src/app/[domain]/components/notificationDot.tsx b/packages/web/src/app/[domain]/components/notificationDot.tsx new file mode 100644 index 00000000..79624705 --- /dev/null +++ b/packages/web/src/app/[domain]/components/notificationDot.tsx @@ -0,0 +1,9 @@ +import { cn } from "@/lib/utils" + +interface NotificationDotProps { + className?: string +} + +export const NotificationDot = ({ className }: NotificationDotProps) => { + return
+} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/components/reposTable.tsx b/packages/web/src/app/[domain]/repos/components/reposTable.tsx index 2030c4c9..730a2585 100644 --- a/packages/web/src/app/[domain]/repos/components/reposTable.tsx +++ b/packages/web/src/app/[domain]/repos/components/reposTable.tsx @@ -37,6 +37,7 @@ import { useRouter } from "next/navigation" import { useToast } from "@/components/hooks/use-toast"; import { DisplayDate } from "../../components/DisplayDate" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { NotificationDot } from "../../components/notificationDot" // @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS @@ -53,6 +54,7 @@ export type Repo = { imageUrl: string | null indexedCommitHash: string | null latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null + isFirstTimeIndex: boolean } const statusBadgeVariants = cva("", { @@ -111,14 +113,32 @@ export const columns: ColumnDef[] = [ {repo.displayName?.charAt(0) ?? repo.name.charAt(0)}
)} - + + {/* Link to the details page (instead of browse) when the repo is indexing + as the code will not be available yet */} + {repo.displayName || repo.name} + {repo.isFirstTimeIndex && ( + + + + + + + + This is the first time Sourcebot is indexing this repository. It may take a few minutes to complete. + + + )}
) }, @@ -150,7 +170,7 @@ export const columns: ColumnDef[] = [ } return ( - + ) } }, @@ -177,11 +197,11 @@ export const columns: ColumnDef[] = [ const HashComponent = commitUrl ? ( - {smallHash} - + href={commitUrl} + className="font-mono text-sm text-link hover:underline" + > + {smallHash} + ) : ( {smallHash} @@ -337,7 +357,7 @@ export const ReposTable = ({ data }: { data: Repo[] }) => { {headerGroup.headers.map((header) => { return ( - @@ -353,7 +373,7 @@ export const ReposTable = ({ data }: { data: Repo[] }) => { table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( - diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index 31830e46..79c4497b 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -3,14 +3,31 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { withOptionalAuthV2 } from "@/withAuthV2"; import { ReposTable } from "./components/reposTable"; +import { RepoIndexingJobStatus } from "@sourcebot/db"; export default async function ReposPage() { - const repos = await getReposWithLatestJob(); - if (isServiceError(repos)) { - throw new ServiceErrorException(repos); + const _repos = await getReposWithLatestJob(); + if (isServiceError(_repos)) { + throw new ServiceErrorException(_repos); } + const repos = _repos + .map((repo) => ({ + ...repo, + latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null, + isFirstTimeIndex: repo.indexedAt === null && repo.jobs.filter((job) => job.status === RepoIndexingJobStatus.PENDING || job.status === RepoIndexingJobStatus.IN_PROGRESS).length > 0, + })) + .sort((a, b) => { + if (a.isFirstTimeIndex && !b.isFirstTimeIndex) { + return -1; + } + if (!a.isFirstTimeIndex && b.isFirstTimeIndex) { + return 1; + } + return a.name.localeCompare(b.name); + }); + return ( <>
@@ -27,7 +44,8 @@ export default async function ReposPage() { createdAt: repo.createdAt, webUrl: repo.webUrl, imageUrl: repo.imageUrl, - latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null, + latestJobStatus: repo.latestJobStatus, + isFirstTimeIndex: repo.isFirstTimeIndex, codeHostType: repo.external_codeHostType, indexedCommitHash: repo.indexedCommitHash, }))} /> diff --git a/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx index 393773ec..78b5a7cc 100644 --- a/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx +++ b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx @@ -1,15 +1,17 @@ "use client" -import React from "react" +import { buttonVariants } from "@/components/ui/button" +import { NotificationDot } from "@/app/[domain]/components/notificationDot" +import { cn } from "@/lib/utils" import Link from "next/link" import { usePathname } from "next/navigation" -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +import React from "react" export type SidebarNavItem = { href: string hrefRegex?: string title: React.ReactNode + isNotificationDotVisible?: boolean } interface SidebarNavProps extends React.HTMLAttributes { @@ -43,6 +45,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) { )} > {item.title} + {item.isNotificationDotVisible && } ) })} diff --git a/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx b/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx index 299a012c..cae2d9c4 100644 --- a/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx +++ b/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx @@ -1,12 +1,14 @@ "use client" import { DisplayDate } from "@/app/[domain]/components/DisplayDate" +import { NotificationDot } from "@/app/[domain]/components/notificationDot" import { useToast } from "@/components/hooks/use-toast" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { CodeHostType, getCodeHostIcon } from "@/lib/utils" import { @@ -35,6 +37,7 @@ export type Connection = { syncedAt: Date | null codeHostType: CodeHostType latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null + isFirstTimeSync: boolean } const statusBadgeVariants = cva("", { @@ -91,6 +94,18 @@ export const columns: ColumnDef[] = [ {connection.name} + {connection.isFirstTimeSync && ( + + + + + + + + This is the first time Sourcebot is syncing this connection. It may take a few minutes to complete. + + + )}
) }, diff --git a/packages/web/src/app/[domain]/settings/connections/page.tsx b/packages/web/src/app/[domain]/settings/connections/page.tsx index 30d803a5..0701a312 100644 --- a/packages/web/src/app/[domain]/settings/connections/page.tsx +++ b/packages/web/src/app/[domain]/settings/connections/page.tsx @@ -4,15 +4,33 @@ import { CodeHostType, isServiceError } from "@/lib/utils"; import { withAuthV2 } from "@/withAuthV2"; import Link from "next/link"; import { ConnectionsTable } from "./components/connectionsTable"; +import { ConnectionSyncJobStatus } from "@prisma/client"; const DOCS_URL = "https://docs.sourcebot.dev/docs/connections/overview"; export default async function ConnectionsPage() { - const connections = await getConnectionsWithLatestJob(); - if (isServiceError(connections)) { - throw new ServiceErrorException(connections); + const _connections = await getConnectionsWithLatestJob(); + if (isServiceError(_connections)) { + throw new ServiceErrorException(_connections); } + // Sort connections so that first time syncs are at the top. + const connections = _connections + .map((connection) => ({ + ...connection, + isFirstTimeSync: connection.syncedAt === null && connection.syncJobs.filter((job) => job.status === ConnectionSyncJobStatus.PENDING || job.status === ConnectionSyncJobStatus.IN_PROGRESS).length > 0, + latestJobStatus: connection.syncJobs.length > 0 ? connection.syncJobs[0].status : null, + })) + .sort((a, b) => { + if (a.isFirstTimeSync && !b.isFirstTimeSync) { + return -1; + } + if (!a.isFirstTimeSync && b.isFirstTimeSync) { + return 1; + } + return a.name.localeCompare(b.name); + }); + return (
@@ -24,7 +42,8 @@ export default async function ConnectionsPage() { name: connection.name, codeHostType: connection.connectionType as CodeHostType, syncedAt: connection.syncedAt, - latestJobStatus: connection.syncJobs.length > 0 ? connection.syncJobs[0].status : null, + latestJobStatus: connection.latestJobStatus, + isFirstTimeSync: connection.isFirstTimeSync, }))} />
) @@ -34,16 +53,22 @@ const getConnectionsWithLatestJob = async () => sew(() => withAuthV2(async ({ prisma }) => { const connections = await prisma.connection.findMany({ include: { + _count: { + select: { + syncJobs: true, + } + }, syncJobs: { orderBy: { createdAt: 'desc' }, take: 1 - } + }, }, orderBy: { name: 'asc' - } + }, }); + return connections; })); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index fcb11f66..a68c54f1 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -6,7 +6,7 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { isServiceError } from "@/lib/utils"; -import { getMe, getOrgAccountRequests } from "@/actions"; +import { getConnectionStats, getMe, getOrgAccountRequests } from "@/actions"; import { ServiceErrorException } from "@/lib/serviceError"; import { getOrgFromDomain } from "@/data/org"; import { OrgRole } from "@prisma/client"; @@ -63,6 +63,11 @@ export default async function SettingsLayout( numJoinRequests = requests.length; } + const connectionStats = await getConnectionStats(); + if (isServiceError(connectionStats)) { + throw new ServiceErrorException(connectionStats); + } + const sidebarNavItems: SidebarNavItem[] = [ { title: "General", @@ -98,6 +103,7 @@ export default async function SettingsLayout( title: "Connections", href: `/${domain}/settings/connections`, hrefRegex: `/${domain}/settings/connections(\/[^/]+)?$`, + isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0, } ] : []), { @@ -140,4 +146,5 @@ export default async function SettingsLayout(
) -} \ No newline at end of file +} +