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 { Settings, WithRequired } from "./types.js";
|
||||
import { Settings } from "./types.js";
|
||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||
import { createLogger } from "./logger.js";
|
||||
import os from 'os';
|
||||
|
|
@ -24,7 +24,7 @@ type JobPayload = {
|
|||
};
|
||||
|
||||
type JobResult = {
|
||||
repoCount: number
|
||||
repoCount: number,
|
||||
}
|
||||
|
||||
export class ConnectionManager implements IConnectionManager {
|
||||
|
|
@ -82,7 +82,7 @@ export class ConnectionManager implements IConnectionManager {
|
|||
}, this.settings.resyncConnectionPollingIntervalMs);
|
||||
}
|
||||
|
||||
private async runSyncJob(job: Job<JobPayload>) {
|
||||
private async runSyncJob(job: Job<JobPayload>): Promise<JobResult> {
|
||||
const { config, orgId } = job.data;
|
||||
// @note: We aren't actually doing anything with this atm.
|
||||
const abortController = new AbortController();
|
||||
|
|
@ -105,6 +105,7 @@ export class ConnectionManager implements IConnectionManager {
|
|||
id: job.data.connectionId,
|
||||
},
|
||||
data: {
|
||||
syncStatus: ConnectionSyncStatus.SYNCING,
|
||||
syncStatusMetadata: {}
|
||||
}
|
||||
})
|
||||
|
|
@ -233,12 +234,25 @@ export class ConnectionManager implements IConnectionManager {
|
|||
this.logger.info(`Connection sync job ${job.id} completed`);
|
||||
const { connectionId } = job.data;
|
||||
|
||||
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
|
||||
where: { id: connectionId },
|
||||
select: { syncStatusMetadata: true }
|
||||
}))?.syncStatusMetadata as Record<string, unknown> ?? {};
|
||||
const { notFound } = syncStatusMetadata as { notFound: {
|
||||
users: string[],
|
||||
orgs: string[],
|
||||
repos: string[],
|
||||
}};
|
||||
|
||||
await this.db.connection.update({
|
||||
where: {
|
||||
id: connectionId,
|
||||
},
|
||||
data: {
|
||||
syncStatus: ConnectionSyncStatus.SYNCED,
|
||||
syncStatus:
|
||||
notFound.users.length > 0 ||
|
||||
notFound.orgs.length > 0 ||
|
||||
notFound.repos.length > 0 ? ConnectionSyncStatus.SYNCED_WITH_WARNINGS : ConnectionSyncStatus.SYNCED,
|
||||
syncedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
|
|||
const tagGlobs = config.revisions.tags;
|
||||
allRepos = await Promise.all(
|
||||
allRepos.map(async (allRepos) => {
|
||||
const [owner, name] = allRepos.name!.split('/');
|
||||
const [owner, name] = allRepos.full_name!.split('/');
|
||||
let tags = (await fetchWithRetry(
|
||||
() => getTagsForRepo(owner, name, api),
|
||||
`tags for ${owner}/${name}`,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
|||
export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => {
|
||||
const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
||||
const token = tokenResult?.token ?? FALLBACK_GITLAB_TOKEN;
|
||||
const secretKey = tokenResult?.secretKey;
|
||||
|
||||
const api = new Gitlab({
|
||||
...(token ? {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "ConnectionSyncStatus" ADD VALUE 'SYNCED_WITH_WARNINGS';
|
||||
|
|
@ -26,6 +26,7 @@ enum ConnectionSyncStatus {
|
|||
IN_SYNC_QUEUE
|
||||
SYNCING
|
||||
SYNCED
|
||||
SYNCED_WITH_WARNINGS
|
||||
FAILED
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,11 +26,7 @@ const nextConfig = {
|
|||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'gitlab.com',
|
||||
hostname: '**',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
|||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||
import { encrypt } from "@sourcebot/crypto"
|
||||
import { getConnection, getLinkedRepos } from "./data/connection";
|
||||
import { ConnectionSyncStatus, Prisma, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { getConnection } from "./data/connection";
|
||||
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { headers } from "next/headers"
|
||||
import { getStripe } from "@/lib/stripe"
|
||||
import { getUser } from "@/data/user";
|
||||
|
|
@ -251,23 +251,23 @@ export const deleteSecret = async (key: string, domain: string): Promise<{ succe
|
|||
}));
|
||||
|
||||
|
||||
export const getConnections = async (domain: string): Promise<
|
||||
{
|
||||
id: number,
|
||||
name: string,
|
||||
syncStatus: ConnectionSyncStatus,
|
||||
syncStatusMetadata: Prisma.JsonValue,
|
||||
connectionType: string,
|
||||
updatedAt: Date,
|
||||
syncedAt?: Date
|
||||
}[] | ServiceError
|
||||
> =>
|
||||
export const getConnections = async (domain: string, filter: { status?: ConnectionSyncStatus[] } = {}) =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const connections = await prisma.connection.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
...(filter.status ? {
|
||||
syncStatus: { in: filter.status }
|
||||
} : {}),
|
||||
},
|
||||
include: {
|
||||
repos: {
|
||||
include: {
|
||||
repo: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return connections.map((connection) => ({
|
||||
|
|
@ -278,45 +278,78 @@ export const getConnections = async (domain: string): Promise<
|
|||
connectionType: connection.connectionType,
|
||||
updatedAt: connection.updatedAt,
|
||||
syncedAt: connection.syncedAt ?? undefined,
|
||||
linkedRepos: connection.repos.map(({ repo }) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
repoIndexingStatus: repo.repoIndexingStatus,
|
||||
})),
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
export const getConnectionFailedRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
|
||||
export const getConnectionInfo = async (connectionId: number, domain: string) =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const connection = await getConnection(connectionId, orgId);
|
||||
const connection = await prisma.connection.findUnique({
|
||||
where: {
|
||||
id: connectionId,
|
||||
orgId,
|
||||
},
|
||||
include: {
|
||||
repos: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const linkedRepos = await getLinkedRepos(connectionId, orgId);
|
||||
|
||||
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
|
||||
repoId: repo.repo.id,
|
||||
repoName: repo.repo.name,
|
||||
}));
|
||||
return {
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
syncStatus: connection.syncStatus,
|
||||
syncStatusMetadata: connection.syncStatusMetadata,
|
||||
connectionType: connection.connectionType,
|
||||
updatedAt: connection.updatedAt,
|
||||
syncedAt: connection.syncedAt ?? undefined,
|
||||
numLinkedRepos: connection.repos.length,
|
||||
}
|
||||
})
|
||||
);
|
||||
)
|
||||
|
||||
export const getConnectionInProgressRepos = async (connectionId: number, domain: string): Promise<{ repoId: number, repoName: string }[] | ServiceError> =>
|
||||
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const connection = await getConnection(connectionId, orgId);
|
||||
if (!connection) {
|
||||
return notFound();
|
||||
const repos = await prisma.repo.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
...(filter.status ? {
|
||||
repoIndexingStatus: { in: filter.status }
|
||||
} : {}),
|
||||
...(filter.connectionId ? {
|
||||
connections: {
|
||||
some: {
|
||||
connectionId: filter.connectionId
|
||||
}
|
||||
}
|
||||
} : {}),
|
||||
},
|
||||
include: {
|
||||
connections: true,
|
||||
}
|
||||
});
|
||||
|
||||
const linkedRepos = await getLinkedRepos(connectionId, orgId);
|
||||
|
||||
return linkedRepos.filter((repo) => repo.repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repo.repoIndexingStatus === RepoIndexingStatus.INDEXING).map((repo) => ({
|
||||
repoId: repo.repo.id,
|
||||
repoName: repo.repo.name,
|
||||
return repos.map((repo) => ({
|
||||
repoId: repo.id,
|
||||
repoName: repo.name,
|
||||
linkedConnections: repo.connections.map((connection) => connection.connectionId),
|
||||
imageUrl: repo.imageUrl ?? undefined,
|
||||
indexedAt: repo.indexedAt ?? undefined,
|
||||
repoIndexingStatus: repo.repoIndexingStatus,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -339,41 +372,6 @@ export const createConnection = async (name: string, type: string, connectionCon
|
|||
}
|
||||
}));
|
||||
|
||||
export const getConnectionInfoAction = async (connectionId: number, domain: string): Promise<{ connection: Connection, linkedRepos: Repo[] } | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const connection = await getConnection(connectionId, orgId);
|
||||
if (!connection) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const linkedRepos = await getLinkedRepos(connectionId, orgId);
|
||||
|
||||
return {
|
||||
connection,
|
||||
linkedRepos: linkedRepos.map((repo) => repo.repo),
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export const getOrgFromDomainAction = async (domain: string): Promise<Org | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return org;
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -458,22 +456,13 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
|
|||
}
|
||||
}));
|
||||
|
||||
export const flagRepoForIndex = async (repoId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||
export const flagReposForIndex = async (repoIds: number[], domain: string) =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async () => {
|
||||
const repo = await prisma.repo.findUnique({
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
await prisma.repo.updateMany({
|
||||
where: {
|
||||
id: repoId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
await prisma.repo.update({
|
||||
where: {
|
||||
id: repoId,
|
||||
id: { in: repoIds },
|
||||
orgId,
|
||||
},
|
||||
data: {
|
||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
||||
|
|
@ -486,8 +475,6 @@ export const flagRepoForIndex = async (repoId: number, domain: string): Promise<
|
|||
})
|
||||
);
|
||||
|
||||
|
||||
|
||||
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -654,7 +641,6 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
|
|||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||
);
|
||||
|
||||
|
||||
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
|
||||
withAuth(async (session) => {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
|
|
@ -828,97 +814,6 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
|
|||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||
);
|
||||
|
||||
const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||
let parsedConfig: ConnectionConfig;
|
||||
try {
|
||||
parsedConfig = JSON.parse(config);
|
||||
} catch (_e) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "config must be a valid JSON object."
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const schema = (() => {
|
||||
switch (connectionType) {
|
||||
case "github":
|
||||
return githubSchema;
|
||||
case "gitlab":
|
||||
return gitlabSchema;
|
||||
case 'gitea':
|
||||
return giteaSchema;
|
||||
case 'gerrit':
|
||||
return gerritSchema;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!schema) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "invalid connection type",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const { numRepos, hasToken } = (() => {
|
||||
switch (connectionType) {
|
||||
case "github": {
|
||||
const githubConfig = parsedConfig as GithubConnectionConfig;
|
||||
return {
|
||||
numRepos: githubConfig.repos?.length,
|
||||
hasToken: !!githubConfig.token,
|
||||
}
|
||||
}
|
||||
case "gitlab": {
|
||||
const gitlabConfig = parsedConfig as GitlabConnectionConfig;
|
||||
return {
|
||||
numRepos: gitlabConfig.projects?.length,
|
||||
hasToken: !!gitlabConfig.token,
|
||||
}
|
||||
}
|
||||
case "gitea": {
|
||||
const giteaConfig = parsedConfig as GiteaConnectionConfig;
|
||||
return {
|
||||
numRepos: giteaConfig.repos?.length,
|
||||
hasToken: !!giteaConfig.token,
|
||||
}
|
||||
}
|
||||
case "gerrit": {
|
||||
const gerritConfig = parsedConfig as GerritConnectionConfig;
|
||||
return {
|
||||
numRepos: gerritConfig.projects?.length,
|
||||
hasToken: true, // gerrit doesn't use a token atm
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
numRepos: undefined,
|
||||
hasToken: true
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (!hasToken && numRepos && numRepos > CONFIG_MAX_REPOS_NO_TOKEN) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `You must provide a token to sync more than ${CONFIG_MAX_REPOS_NO_TOKEN} repositories.`,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const isValidConfig = ajv.validate(schema, parsedConfig);
|
||||
if (!isValidConfig) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
return parsedConfig;
|
||||
}
|
||||
|
||||
export const createOnboardingStripeCheckoutSession = async (domain: string) =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -1071,8 +966,6 @@ export const createStripeCheckoutSession = async (domain: string) =>
|
|||
})
|
||||
)
|
||||
|
||||
|
||||
|
||||
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -1104,32 +997,6 @@ export const fetchSubscription = (domain: string): Promise<Stripe.Subscription |
|
|||
})
|
||||
);
|
||||
|
||||
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!org.stripeCustomerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripe = getStripe();
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: org.stripeCustomerId
|
||||
});
|
||||
|
||||
if (subscriptions.data.length === 0) {
|
||||
return orgInvalidSubscription();
|
||||
}
|
||||
return subscriptions.data[0];
|
||||
}
|
||||
|
||||
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -1176,16 +1043,6 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
|
|||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||
);
|
||||
|
||||
export const checkIfUserHasOrg = async (userId: string): Promise<boolean | ServiceError> => {
|
||||
const orgs = await prisma.userToOrg.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return orgs.length > 0;
|
||||
}
|
||||
|
||||
export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> =>
|
||||
withAuth(async () => {
|
||||
const org = await prisma.org.findFirst({
|
||||
|
|
@ -1357,7 +1214,6 @@ export const getOrgMembers = async (domain: string) =>
|
|||
})
|
||||
);
|
||||
|
||||
|
||||
export const getOrgInvites = async (domain: string) =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -1374,3 +1230,123 @@ export const getOrgInvites = async (domain: string) =>
|
|||
}));
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
////// Helpers ///////
|
||||
|
||||
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!org.stripeCustomerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripe = getStripe();
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: org.stripeCustomerId
|
||||
});
|
||||
|
||||
if (subscriptions.data.length === 0) {
|
||||
return orgInvalidSubscription();
|
||||
}
|
||||
return subscriptions.data[0];
|
||||
}
|
||||
|
||||
const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||
let parsedConfig: ConnectionConfig;
|
||||
try {
|
||||
parsedConfig = JSON.parse(config);
|
||||
} catch (_e) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "config must be a valid JSON object."
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const schema = (() => {
|
||||
switch (connectionType) {
|
||||
case "github":
|
||||
return githubSchema;
|
||||
case "gitlab":
|
||||
return gitlabSchema;
|
||||
case 'gitea':
|
||||
return giteaSchema;
|
||||
case 'gerrit':
|
||||
return gerritSchema;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!schema) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "invalid connection type",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const { numRepos, hasToken } = (() => {
|
||||
switch (connectionType) {
|
||||
case "github": {
|
||||
const githubConfig = parsedConfig as GithubConnectionConfig;
|
||||
return {
|
||||
numRepos: githubConfig.repos?.length,
|
||||
hasToken: !!githubConfig.token,
|
||||
}
|
||||
}
|
||||
case "gitlab": {
|
||||
const gitlabConfig = parsedConfig as GitlabConnectionConfig;
|
||||
return {
|
||||
numRepos: gitlabConfig.projects?.length,
|
||||
hasToken: !!gitlabConfig.token,
|
||||
}
|
||||
}
|
||||
case "gitea": {
|
||||
const giteaConfig = parsedConfig as GiteaConnectionConfig;
|
||||
return {
|
||||
numRepos: giteaConfig.repos?.length,
|
||||
hasToken: !!giteaConfig.token,
|
||||
}
|
||||
}
|
||||
case "gerrit": {
|
||||
const gerritConfig = parsedConfig as GerritConnectionConfig;
|
||||
return {
|
||||
numRepos: gerritConfig.projects?.length,
|
||||
hasToken: true, // gerrit doesn't use a token atm
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
numRepos: undefined,
|
||||
hasToken: true
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (!hasToken && numRepos && numRepos > CONFIG_MAX_REPOS_NO_TOKEN) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `You must provide a token to sync more than ${CONFIG_MAX_REPOS_NO_TOKEN} repositories.`,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const isValidConfig = ajv.validate(schema, parsedConfig);
|
||||
if (!isValidConfig) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
return parsedConfig;
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, isServiceError } from "@/lib/utils";
|
||||
import { cn, isServiceError, unwrapServiceError } from "@/lib/utils";
|
||||
import { ChevronsUpDown, Check, PlusCircleIcon, Loader2, Eye, EyeOff, TriangleAlert } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
|
@ -49,9 +49,9 @@ export const SecretCombobox = ({
|
|||
const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const { data: secrets, isLoading, refetch } = useQuery({
|
||||
const { data: secrets, isPending, isError, refetch } = useQuery({
|
||||
queryKey: ["secrets"],
|
||||
queryFn: () => getSecrets(domain),
|
||||
queryFn: () => unwrapServiceError(getSecrets(domain)),
|
||||
});
|
||||
|
||||
const onSecretCreated = useCallback((key: string) => {
|
||||
|
|
@ -59,16 +59,6 @@ export const SecretCombobox = ({
|
|||
refetch();
|
||||
}, [onSecretChange, refetch]);
|
||||
|
||||
const isSecretNotFoundWarningVisible = useMemo(() => {
|
||||
if (!isDefined(secretKey)) {
|
||||
return false;
|
||||
}
|
||||
if (isServiceError(secrets)) {
|
||||
return false;
|
||||
}
|
||||
return !secrets?.some(({ key }) => key === secretKey);
|
||||
}, [secretKey, secrets]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
|
|
@ -83,7 +73,7 @@ export const SecretCombobox = ({
|
|||
)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isSecretNotFoundWarningVisible && (
|
||||
{!(isPending || isError) && isDefined(secretKey) && !secrets.some(({ key }) => key === secretKey) && (
|
||||
<TooltipProvider>
|
||||
|
||||
<Tooltip
|
||||
|
|
@ -105,12 +95,13 @@ export const SecretCombobox = ({
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0.5">
|
||||
{isLoading && (
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{secrets && !isServiceError(secrets) && secrets.length > 0 && (
|
||||
) : isError ? (
|
||||
<p className="p-2 text-sm text-destructive">Failed to load secrets</p>
|
||||
) : secrets.length > 0 && (
|
||||
<>
|
||||
<Command className="mb-2">
|
||||
<CommandInput
|
||||
|
|
|
|||
|
|
@ -4,95 +4,56 @@ import Link from "next/link";
|
|||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { CircleXIcon } from "lucide-react";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { getConnectionFailedRepos, getConnections } from "@/actions";
|
||||
import { useState, useEffect } from "react";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
enum ConnectionErrorType {
|
||||
SYNC_FAILED = "SYNC_FAILED",
|
||||
REPO_INDEXING_FAILED = "REPO_INDEXING_FAILED",
|
||||
}
|
||||
|
||||
interface Error {
|
||||
connectionId?: number;
|
||||
connectionName?: string;
|
||||
errorType: ConnectionErrorType;
|
||||
numRepos?: number;
|
||||
}
|
||||
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
|
||||
import { getConnections } from "@/actions";
|
||||
import { getRepos } from "@/actions";
|
||||
|
||||
export const ErrorNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const [errors, setErrors] = useState<Error[]>([]);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchErrors = async () => {
|
||||
const connections = await getConnections(domain);
|
||||
const errors: Error[] = [];
|
||||
if (!isServiceError(connections)) {
|
||||
for (const connection of connections) {
|
||||
if (connection.syncStatus === 'FAILED') {
|
||||
errors.push({
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name,
|
||||
errorType: ConnectionErrorType.SYNC_FAILED
|
||||
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
|
||||
queryKey: ['repos', domain],
|
||||
queryFn: () => unwrapServiceError(getRepos(domain)),
|
||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
|
||||
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const { data: connections, isPending: isPendingConnections, isError: isErrorConnections } = useQuery({
|
||||
queryKey: ['connections', domain],
|
||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.FAILED),
|
||||
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
|
||||
if (isPendingRepos || isErrorRepos || isPendingConnections || isErrorConnections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
|
||||
if (!isServiceError(failedRepos)) {
|
||||
if (failedRepos.length > 0) {
|
||||
errors.push({
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name,
|
||||
numRepos: failedRepos.length,
|
||||
errorType: ConnectionErrorType.REPO_INDEXING_FAILED
|
||||
});
|
||||
if (repos.length === 0 && connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
} 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();
|
||||
}, [domain, captureEvent]);
|
||||
|
||||
if (errors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
|
||||
<CircleXIcon className="h-4 w-4" />
|
||||
{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0) > 0 && (
|
||||
<span>{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0)}</span>
|
||||
{repos.length + connections.length > 0 && (
|
||||
<span>{repos.length + connections.length}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
{errors.filter(e => e.errorType === 'SYNC_FAILED').length > 0 && (
|
||||
<div className="flex flex-col gap-4 border-b border-red-200 dark:border-red-800 pb-6">
|
||||
{connections.length > 0 && (
|
||||
<div className="flex flex-col gap-4 pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Connection Sync Issues</h3>
|
||||
|
|
@ -100,63 +61,57 @@ export const ErrorNavIndicator = () => {
|
|||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
||||
The following connections have failed to sync:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
{errors
|
||||
.filter(e => e.errorType === 'SYNC_FAILED')
|
||||
<div className="flex flex-col gap-2">
|
||||
{connections
|
||||
.slice(0, 10)
|
||||
.map(error => (
|
||||
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
||||
.map(connection => (
|
||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
||||
rounded-md text-sm text-red-700 dark:text-red-300
|
||||
border border-red-200/50 dark:border-red-800/50
|
||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
||||
<span className="font-medium">{error.connectionName}</span>
|
||||
<span className="font-medium">{connection.name}</span>
|
||||
</div>
|
||||
</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">
|
||||
And {errors.filter(e => e.errorType === 'SYNC_FAILED').length - 10} more...
|
||||
And {connections.length - 10} more...
|
||||
</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 items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Repository Indexing Issues</h3>
|
||||
</div>
|
||||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
||||
The following connections have repositories that failed to index:
|
||||
The following repositories failed to index:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
{errors
|
||||
.filter(e => e.errorType === 'REPO_INDEXING_FAILED')
|
||||
<div className="flex flex-col gap-2">
|
||||
{repos
|
||||
.slice(0, 10)
|
||||
.map(error => (
|
||||
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
||||
.map(repo => (
|
||||
// Link to the first connection for the repo
|
||||
<Link key={repo.repoId} href={`/${domain}/connections/${repo.linkedConnections[0]}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
||||
<div className="flex items-center justify-between px-3 py-2
|
||||
bg-red-50 dark:bg-red-900/20 rounded-md
|
||||
border border-red-200/50 dark:border-red-800/50
|
||||
hover:bg-red-100 dark:hover:bg-red-900/30
|
||||
transition-colors">
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{error.connectionName}
|
||||
</span>
|
||||
<span className="text-xs font-medium px-2.5 py-1 rounded-full
|
||||
bg-red-100/80 dark:bg-red-800/60
|
||||
text-red-600 dark:text-red-300">
|
||||
{error.numRepos}
|
||||
{repo.repoName}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{errors.filter(e => e.errorType === 'REPO_INDEXING_FAILED').length > 10 && (
|
||||
{repos.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...
|
||||
And {repos.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -165,6 +120,5 @@ export const ErrorNavIndicator = () => {
|
|||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,73 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { getConnectionInProgressRepos, getConnections } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { getRepos } from "@/actions";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface InProgress {
|
||||
connectionId: number;
|
||||
repoId: number;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import { RepoIndexingStatus } from "@prisma/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const ProgressNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const [inProgressJobs, setInProgressJobs] = useState<InProgress[]>([]);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInProgressJobs = async () => {
|
||||
const connections = await getConnections(domain);
|
||||
if (!isServiceError(connections)) {
|
||||
const allInProgressRepos: InProgress[] = [];
|
||||
for (const connection of connections) {
|
||||
const inProgressRepos = await getConnectionInProgressRepos(connection.id, domain);
|
||||
if (!isServiceError(inProgressRepos)) {
|
||||
allInProgressRepos.push(...inProgressRepos.map(repo => ({
|
||||
connectionId: connection.id,
|
||||
...repo
|
||||
})));
|
||||
} else {
|
||||
captureEvent('wa_progress_nav_job_fetch_fail', {
|
||||
error: inProgressRepos.errorCode,
|
||||
const { data: inProgressRepos, isPending, isError } = useQuery({
|
||||
queryKey: ['repos', domain],
|
||||
queryFn: () => unwrapServiceError(getRepos(domain)),
|
||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
|
||||
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
setInProgressJobs(prevJobs => {
|
||||
// Only update if the jobs have actually changed
|
||||
const jobsChanged = prevJobs.length !== allInProgressRepos.length ||
|
||||
prevJobs.some((job, idx) =>
|
||||
job.repoId !== allInProgressRepos[idx]?.repoId ||
|
||||
job.repoName !== allInProgressRepos[idx]?.repoName
|
||||
);
|
||||
return jobsChanged ? allInProgressRepos : prevJobs;
|
||||
});
|
||||
} else {
|
||||
captureEvent('wa_progress_nav_connection_fetch_fail', {
|
||||
error: connections.errorCode,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchInProgressJobs();
|
||||
}, [domain, captureEvent]);
|
||||
|
||||
if (inProgressJobs.length === 0) {
|
||||
if (isPending || isError || inProgressRepos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_progress_nav_pressed', {})}>
|
||||
<Link
|
||||
href={`/${domain}/connections`}
|
||||
onClick={() => captureEvent('wa_progress_nav_pressed', {})}
|
||||
>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
<span>{inProgressJobs.length}</span>
|
||||
<span>{inProgressRepos.length}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
|
|
@ -80,8 +48,9 @@ export const ProgressNavIndicator = () => {
|
|||
The following repositories are currently being indexed:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
{inProgressJobs.slice(0, 10).map(item => (
|
||||
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`} onClick={() => captureEvent('wa_progress_nav_job_pressed', {})}>
|
||||
{inProgressRepos.slice(0, 10).map(item => (
|
||||
// Link to the first connection for the repo
|
||||
<Link key={item.repoId} href={`/${domain}/connections/${item.linkedConnections[0]}`} onClick={() => captureEvent('wa_progress_nav_job_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
|
||||
rounded-md text-sm text-green-700 dark:text-green-300
|
||||
border border-green-200/50 dark:border-green-800/50
|
||||
|
|
@ -90,9 +59,9 @@ export const ProgressNavIndicator = () => {
|
|||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{inProgressJobs.length > 10 && (
|
||||
{inProgressRepos.length > 10 && (
|
||||
<div className="text-sm text-green-600/90 dark:text-green-300/90 pl-3 pt-1">
|
||||
And {inProgressJobs.length - 10} more...
|
||||
And {inProgressRepos.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,56 +5,24 @@ import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/h
|
|||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { getConnections } from "@/actions";
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface Warning {
|
||||
connectionId?: number;
|
||||
connectionName?: string;
|
||||
}
|
||||
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ConnectionSyncStatus } from "@prisma/client";
|
||||
|
||||
export const WarningNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const [warnings, setWarnings] = useState<Warning[]>([]);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWarnings = async () => {
|
||||
const connections = await getConnections(domain);
|
||||
const warnings: Warning[] = [];
|
||||
if (!isServiceError(connections)) {
|
||||
for (const connection of connections) {
|
||||
const parseResult = SyncStatusMetadataSchema.safeParse(connection.syncStatusMetadata);
|
||||
if (parseResult.success && parseResult.data.notFound) {
|
||||
const { notFound } = parseResult.data;
|
||||
if (notFound.users.length > 0 || notFound.orgs.length > 0 || notFound.repos.length > 0) {
|
||||
warnings.push({ connectionId: connection.id, connectionName: connection.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
captureEvent('wa_warning_nav_connection_fetch_fail', {
|
||||
error: connections.errorCode,
|
||||
const { data: connections, isPending, isError } = useQuery({
|
||||
queryKey: ['connections', domain],
|
||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.SYNCED_WITH_WARNINGS),
|
||||
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
setWarnings(prevWarnings => {
|
||||
// Only update if the warnings have actually changed
|
||||
const warningsChanged = prevWarnings.length !== warnings.length ||
|
||||
prevWarnings.some((warning, idx) =>
|
||||
warning.connectionId !== warnings[idx]?.connectionId ||
|
||||
warning.connectionName !== warnings[idx]?.connectionName
|
||||
);
|
||||
return warningsChanged ? warnings : prevWarnings;
|
||||
});
|
||||
};
|
||||
|
||||
fetchWarnings();
|
||||
}, [domain, captureEvent]);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
if (isPending || isError || connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +32,7 @@ export const WarningNavIndicator = () => {
|
|||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<span>{warnings.length}</span>
|
||||
<span>{connections.length}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
|
|
@ -77,19 +45,19 @@ export const WarningNavIndicator = () => {
|
|||
The following connections have references that could not be found:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
{warnings.slice(0, 10).map(warning => (
|
||||
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
|
||||
{connections.slice(0, 10).map(connection => (
|
||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
|
||||
rounded-md text-sm text-yellow-700 dark:text-yellow-300
|
||||
border border-yellow-200/50 dark:border-yellow-800/50
|
||||
hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
|
||||
<span className="font-medium">{warning.connectionName}</span>
|
||||
<span className="font-medium">{connection.name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{warnings.length > 10 && (
|
||||
{connections.length > 10 && (
|
||||
<div className="text-sm text-yellow-600/90 dark:text-yellow-300/90 pl-3 pt-1">
|
||||
And {warnings.length - 10} more...
|
||||
And {connections.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -154,13 +154,6 @@ function ConfigSettingInternal<T>({
|
|||
onConfigChange(config);
|
||||
}, [config, onConfigChange]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("mount");
|
||||
return () => {
|
||||
console.log("unmount");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-2">Configuration</h3>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { Prisma } from "@sourcebot/db"
|
||||
import { RetrySyncButton } from "./retrySyncButton"
|
||||
import { Prisma, ConnectionSyncStatus } from "@sourcebot/db"
|
||||
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema"
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface NotFoundWarningProps {
|
||||
syncStatus: ConnectionSyncStatus
|
||||
syncStatusMetadata: Prisma.JsonValue
|
||||
onSecretsClick: () => void
|
||||
connectionId: number
|
||||
domain: 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 parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
|
||||
if (!parseResult.success || !parseResult.data.notFound) {
|
||||
if (syncStatus !== ConnectionSyncStatus.SYNCED_WITH_WARNINGS || !parseResult.success || !parseResult.data.notFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +65,15 @@ export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connection
|
|||
)}
|
||||
</ul>
|
||||
<div className="w-full flex justify-center">
|
||||
<RetrySyncButton connectionId={connectionId} domain={domain} />
|
||||
<Button
|
||||
variant="outline"
|
||||
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,7 +3,7 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons"
|
||||
import { toast } from "@/components/hooks/use-toast";
|
||||
import { flagRepoForIndex } from "@/actions";
|
||||
import { flagReposForIndex } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonPro
|
|||
size="sm"
|
||||
className="ml-2"
|
||||
onClick={async () => {
|
||||
const result = await flagRepoForIndex(repoId, domain);
|
||||
const result = await flagReposForIndex([repoId], domain);
|
||||
if (isServiceError(result)) {
|
||||
toast({
|
||||
description: `❌ Failed to flag repository for indexing.`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
Breadcrumb,
|
||||
|
|
@ -9,7 +7,6 @@ import {
|
|||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { TabSwitcher } from "@/components/ui/tab-switcher"
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs"
|
||||
import { ConnectionIcon } from "../components/connectionIcon"
|
||||
|
|
@ -17,113 +14,33 @@ import { Header } from "../../components/header"
|
|||
import { ConfigSetting } from "./components/configSetting"
|
||||
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting"
|
||||
import { DisplayNameSetting } from "./components/displayNameSetting"
|
||||
import { RepoListItem } from "./components/repoListItem"
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import type { Connection, Repo, Org } from "@sourcebot/db"
|
||||
import { getConnectionInfoAction, getOrgFromDomainAction } from "@/actions"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
import { DisplayConnectionError } from "./components/connectionError"
|
||||
import { NotFoundWarning } from "./components/notFoundWarning"
|
||||
import { RetrySyncButton } from "./components/retrySyncButton"
|
||||
import { RetryAllFailedReposButton } from "./components/retryAllFailedReposButton"
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { RepoList } from "./components/repoList"
|
||||
import { auth } from "@/auth"
|
||||
import { getConnectionByDomain } from "@/data/connection"
|
||||
import { Overview } from "./components/overview"
|
||||
|
||||
export default function ConnectionManagementPage() {
|
||||
const params = useParams()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [org, setOrg] = useState<Org | null>(null)
|
||||
const [connection, setConnection] = useState<Connection | null>(null)
|
||||
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const captureEvent = useCaptureEvent();
|
||||
interface ConnectionManagementPageProps {
|
||||
params: {
|
||||
domain: string
|
||||
id: string
|
||||
},
|
||||
searchParams: {
|
||||
tab: string
|
||||
}
|
||||
}
|
||||
|
||||
const handleSecretsNavigation = () => {
|
||||
captureEvent('wa_connection_secrets_navigation_pressed', {});
|
||||
router.push(`/${params.domain}/secrets`)
|
||||
export default async function ConnectionManagementPage({ params, searchParams }: ConnectionManagementPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const orgResult = await getOrgFromDomainAction(params.domain as string)
|
||||
if (isServiceError(orgResult)) {
|
||||
setError(orgResult.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setOrg(orgResult)
|
||||
|
||||
const connectionId = Number(params.id)
|
||||
if (isNaN(connectionId)) {
|
||||
setError("Invalid connection ID")
|
||||
setLoading(false)
|
||||
return
|
||||
const connection = await getConnectionByDomain(Number(params.id), params.domain);
|
||||
if (!connection) {
|
||||
return <NotFound className="flex w-full h-full items-center justify-center" message="Connection not found" />
|
||||
}
|
||||
|
||||
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) {
|
||||
return <NotFound className="flex w-full h-full items-center justify-center" message={error || "Not found"} />
|
||||
}
|
||||
|
||||
const currentTab = searchParams.get("tab") || "overview"
|
||||
const currentTab = searchParams.tab || "overview";
|
||||
|
||||
return (
|
||||
<Tabs value={currentTab} className="w-full">
|
||||
|
|
@ -154,75 +71,17 @@ export default function ConnectionManagementPage() {
|
|||
</Header>
|
||||
<TabsContent
|
||||
value="overview"
|
||||
className="space-y-8"
|
||||
>
|
||||
<h1 className="font-semibold text-lg">Overview</h1>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-border p-4 bg-background">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Connection Type</h2>
|
||||
<p className="mt-2 text-sm">{connection.connectionType}</p>
|
||||
<div>
|
||||
<h1 className="font-semibold text-lg mb-4">Overview</h1>
|
||||
<Overview connectionId={connection.id} />
|
||||
</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>
|
||||
<h1 className="font-semibold text-lg mb-4">Linked Repositories</h1>
|
||||
<RepoList connectionId={connection.id} />
|
||||
</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 className="flex justify-between items-center mt-8">
|
||||
<h1 className="font-semibold text-lg">Linked Repositories</h1>
|
||||
<RetryAllFailedReposButton connectionId={connection.id} domain={params.domain as string} />
|
||||
</div>
|
||||
<ScrollArea className="mt-4 max-h-96 overflow-scroll">
|
||||
<div className="flex flex-col gap-4">
|
||||
{linkedRepos.map((repo) => (
|
||||
<RepoListItem
|
||||
key={repo.id}
|
||||
imageUrl={repo.imageUrl ?? undefined}
|
||||
name={repo.name}
|
||||
indexedAt={repo.indexedAt ?? undefined}
|
||||
status={repo.repoIndexingStatus}
|
||||
repoId={repo.id}
|
||||
domain={params.domain as string}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="settings"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
|||
return 'running';
|
||||
case ConnectionSyncStatus.SYNCED:
|
||||
return 'succeeded';
|
||||
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
|
||||
return 'succeeded-with-warnings';
|
||||
case ConnectionSyncStatus.FAILED:
|
||||
return 'failed';
|
||||
}
|
||||
|
|
@ -53,6 +55,8 @@ export const ConnectionListItem = ({
|
|||
return 'Synced';
|
||||
case ConnectionSyncStatus.FAILED:
|
||||
return 'Sync failed';
|
||||
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
|
||||
return null;
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +1,114 @@
|
|||
"use client";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { ConnectionListItem } from "./connectionListItem";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
import { cn, unwrapServiceError } from "@/lib/utils";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
|
||||
import { getConnectionFailedRepos, getConnections } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
||||
import { getConnections } from "@/actions";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
|
||||
import { RepoIndexingStatus, ConnectionSyncStatus } from "@sourcebot/db";
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useMemo, useState } from "react";
|
||||
import { MultiSelect } from "@/components/ui/multi-select";
|
||||
interface ConnectionListProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
||||
switch (status) {
|
||||
case ConnectionSyncStatus.SYNC_NEEDED:
|
||||
return 'waiting';
|
||||
case ConnectionSyncStatus.SYNCING:
|
||||
return 'running';
|
||||
case ConnectionSyncStatus.SYNCED:
|
||||
return 'succeeded';
|
||||
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
|
||||
return 'synced-with-warnings';
|
||||
case ConnectionSyncStatus.FAILED:
|
||||
return 'failed';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export const ConnectionList = ({
|
||||
className,
|
||||
}: ConnectionListProps) => {
|
||||
const domain = useDomain();
|
||||
const [connections, setConnections] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
connectionType: string;
|
||||
syncStatus: ConnectionSyncStatus;
|
||||
syncStatusMetadata: Prisma.JsonValue;
|
||||
updatedAt: Date;
|
||||
syncedAt?: Date;
|
||||
failedRepos?: { repoId: number, repoName: string }[];
|
||||
}[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const result = await getConnections(domain);
|
||||
if (isServiceError(result)) {
|
||||
setError(result.message);
|
||||
} else {
|
||||
const connectionsWithFailedRepos = [];
|
||||
for (const connection of result) {
|
||||
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
|
||||
if (isServiceError(failedRepos)) {
|
||||
setError(`An error occured while fetching the failed repositories for connection ${connection.name}. If the problem persists, please contact us at team@sourcebot.dev`);
|
||||
} else {
|
||||
connectionsWithFailedRepos.push({
|
||||
...connection,
|
||||
failedRepos,
|
||||
const { data: unfilteredConnections, isPending, error } = useQuery({
|
||||
queryKey: ['connections', domain],
|
||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
||||
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
}, [domain]);
|
||||
const connections = useMemo(() => {
|
||||
return unfilteredConnections
|
||||
?.filter((connection) => connection.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.filter((connection) => {
|
||||
if (selectedStatuses.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return selectedStatuses.includes(convertSyncStatus(connection.syncStatus));
|
||||
})
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) ?? [];
|
||||
}, [unfilteredConnections, searchQuery, selectedStatuses]);
|
||||
|
||||
if (error) {
|
||||
return <div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
|
||||
<p>Error loading connections: {error.message}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
|
||||
<p>Loading connections...</p>
|
||||
<div className="flex gap-4 flex-col sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={`Filter ${isPending ? "n" : connections.length} ${connections.length === 1 ? "connection" : "connections"} by name`}
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
|
||||
<p>Error loading connections: {error}</p>
|
||||
|
||||
<MultiSelect
|
||||
className="bg-background hover:bg-background w-56"
|
||||
options={[
|
||||
{ value: 'waiting', label: 'Waiting' },
|
||||
{ value: 'running', label: 'Syncing' },
|
||||
{ value: 'succeeded', label: 'Synced' },
|
||||
{ value: 'synced-with-warnings', label: 'Warnings' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
]}
|
||||
onValueChange={(value) => setSelectedStatuses(value)}
|
||||
defaultValue={[]}
|
||||
placeholder="Filter by status"
|
||||
maxCount={2}
|
||||
animation={0}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
// Skeleton for loading state
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border rounded-md p-4">
|
||||
<Skeleton className="w-8 h-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
<Skeleton className="w-24 h-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : connections.length > 0 ? (
|
||||
connections
|
||||
|
|
@ -84,7 +123,10 @@ export const ConnectionList = ({
|
|||
syncStatusMetadata={connection.syncStatusMetadata}
|
||||
editedAt={connection.updatedAt}
|
||||
syncedAt={connection.syncedAt ?? undefined}
|
||||
failedRepos={connection.failedRepos}
|
||||
failedRepos={connection.linkedRepos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
|
||||
repoId: repo.id,
|
||||
repoName: repo.name,
|
||||
}))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
@ -94,5 +136,5 @@ export const ConnectionList = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { CircleCheckIcon, CircleXIcon } from "lucide-react";
|
|||
import { useMemo } from "react";
|
||||
import { FiLoader } from "react-icons/fi";
|
||||
|
||||
export type Status = 'waiting' | 'running' | 'succeeded' | 'failed' | 'garbage-collecting';
|
||||
export type Status = 'waiting' | 'running' | 'succeeded' | 'succeeded-with-warnings' | 'garbage-collecting' | 'failed';
|
||||
|
||||
export const StatusIcon = ({
|
||||
status,
|
||||
|
|
@ -19,7 +19,9 @@ export const StatusIcon = ({
|
|||
return <CircleCheckIcon className={cn('text-green-600', className)} />;
|
||||
case 'failed':
|
||||
return <CircleXIcon className={cn('text-destructive', className)} />;
|
||||
|
||||
case 'succeeded-with-warnings':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [className, status]);
|
||||
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
||||
export const getLinkedRepos = async (connectionId: number, orgId: number) => {
|
||||
const linkedRepos = await prisma.repoToConnection.findMany({
|
||||
export const getConnectionByDomain = async (connectionId: number, domain: string) => {
|
||||
const connection = await prisma.connection.findUnique({
|
||||
where: {
|
||||
connection: {
|
||||
id: connectionId,
|
||||
orgId: orgId,
|
||||
org: {
|
||||
domain: domain,
|
||||
}
|
||||
},
|
||||
include: {
|
||||
repo: true,
|
||||
}
|
||||
});
|
||||
|
||||
return linkedRepos;
|
||||
return connection;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { prisma } from '@/prisma';
|
||||
import 'server-only';
|
||||
import { prisma } from '@/prisma';
|
||||
|
||||
export const getOrgFromDomain = async (domain: string) => {
|
||||
const org = await prisma.org.findUnique({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'client-only';
|
||||
|
||||
import { getEnv, getEnvBoolean } from "./utils";
|
||||
import { getEnv, getEnvBoolean, getEnvNumber } from "./utils";
|
||||
|
||||
export const NEXT_PUBLIC_POSTHOG_PAPIK = getEnv(process.env.NEXT_PUBLIC_POSTHOG_PAPIK);
|
||||
export const NEXT_PUBLIC_POSTHOG_HOST = getEnv(process.env.NEXT_PUBLIC_POSTHOG_HOST);
|
||||
|
|
@ -10,3 +10,4 @@ export const NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.en
|
|||
export const NEXT_PUBLIC_SOURCEBOT_VERSION = getEnv(process.env.NEXT_PUBLIC_SOURCEBOT_VERSION, "unknown")!;
|
||||
export const NEXT_PUBLIC_DOMAIN_SUB_PATH = getEnv(process.env.NEXT_PUBLIC_DOMAIN_SUB_PATH, "")!;
|
||||
export const NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = getEnv(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||
export const NEXT_PUBLIC_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: {
|
||||
error: string,
|
||||
},
|
||||
wa_progress_nav_job_fetch_fail: {
|
||||
wa_progress_nav_repo_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_progress_nav_hover: {},
|
||||
|
|
@ -205,13 +205,8 @@ export type PosthogEventMap = {
|
|||
wa_connection_retry_all_failed_repos_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_connection_retry_all_failed_repos_fail: {
|
||||
successCount: number,
|
||||
failureCount: number,
|
||||
},
|
||||
wa_connection_retry_all_failed_repos_success: {
|
||||
successCount: number,
|
||||
},
|
||||
wa_connection_retry_all_failed_repos_fail: {},
|
||||
wa_connection_retry_all_failed_repos_success: {},
|
||||
wa_connection_retry_all_failed_no_repos: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_repo_retry_index_success: {},
|
||||
|
|
|
|||
|
|
@ -244,3 +244,20 @@ export const measure = async <T>(cb: () => Promise<T>, measureName: string) => {
|
|||
durationMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a promise that could return a ServiceError, throwing an error if it does.
|
||||
* This is useful for calling server actions in a useQuery hook since it allows us
|
||||
* to take advantage of error handling behavior built into react-query.
|
||||
*
|
||||
* @param promise The promise to unwrap.
|
||||
* @returns The data from the promise.
|
||||
*/
|
||||
export const unwrapServiceError = async <T>(promise: Promise<ServiceError | T>): Promise<T> => {
|
||||
const data = await promise;
|
||||
if (isServiceError(data)) {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
Loading…
Reference in a new issue