mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
Connections UX pass + query optimizations (#212)
This commit is contained in:
parent
b77f55fa20
commit
50b94b2c46
30 changed files with 1430 additions and 948 deletions
|
|
@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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 ? {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ConnectionSyncStatus" ADD VALUE 'SYNCED_WITH_WARNINGS';
|
||||||
|
|
@ -26,6 +26,7 @@ enum ConnectionSyncStatus {
|
||||||
IN_SYNC_QUEUE
|
IN_SYNC_QUEUE
|
||||||
SYNCING
|
SYNCING
|
||||||
SYNCED
|
SYNCED
|
||||||
|
SYNCED_WITH_WARNINGS
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,7 @@ const nextConfig = {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'avatars.githubusercontent.com',
|
hostname: '**',
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'gitlab.com',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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't be found. Please ensure you've provided the information listed below correctly, and that you've provided a{" "}
|
Some requested references couldn't be found. Please ensure you've provided the information listed below correctly, and that you've provided a{" "}
|
||||||
<button onClick={onSecretsClick} className="text-yellow-500 dark:text-yellow-400 font-bold hover:underline">
|
<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're private.
|
to access them if they'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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
36
packages/web/src/components/ui/badge.tsx
Normal file
36
packages/web/src/components/ui/badge.tsx
Normal 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 }
|
||||||
370
packages/web/src/components/ui/multi-select.tsx
Normal file
370
packages/web/src/components/ui/multi-select.tsx
Normal 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";
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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: {},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue