mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
feat(web): Improved repository table (#572)
This commit is contained in:
parent
4b86bcd182
commit
2d3b03bf12
29 changed files with 1456 additions and 547 deletions
|
|
@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Improved homepage performance by removing client side polling. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
- Improved homepage performance by removing client side polling. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
- Changed navbar indexing indicator to only report progress for first time indexing jobs. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
- Changed navbar indexing indicator to only report progress for first time indexing jobs. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
- Improved repo indexing job stability and robustness. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
- Improved repo indexing job stability and robustness. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
|
- Improved repositories table. [#572](https://github.com/sourcebot-dev/sourcebot/pull/572)
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- Removed spam "login page loaded" log. [#552](https://github.com/sourcebot-dev/sourcebot/pull/552)
|
- Removed spam "login page loaded" log. [#552](https://github.com/sourcebot-dev/sourcebot/pull/552)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,6 @@
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { Settings } from "./types.js";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
/**
|
|
||||||
* Default settings.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
|
||||||
maxFileSize: 2 * 1024 * 1024, // 2MB in bytes
|
|
||||||
maxTrigramCount: 20000,
|
|
||||||
reindexIntervalMs: 1000 * 60 * 60, // 1 hour
|
|
||||||
resyncConnectionIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
|
||||||
resyncConnectionPollingIntervalMs: 1000 * 1, // 1 second
|
|
||||||
reindexRepoPollingIntervalMs: 1000 * 1, // 1 second
|
|
||||||
maxConnectionSyncJobConcurrency: 8,
|
|
||||||
maxRepoIndexingJobConcurrency: 8,
|
|
||||||
maxRepoGarbageCollectionJobConcurrency: 8,
|
|
||||||
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
|
|
||||||
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
|
|
||||||
enablePublicAccess: false, // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
|
|
||||||
experiment_repoDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
|
||||||
experiment_userDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
||||||
'github',
|
'github',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -268,4 +268,26 @@ export const getTags = async (path: string) => {
|
||||||
const git = createGitClientForPath(path);
|
const git = createGitClientForPath(path);
|
||||||
const tags = await git.tags();
|
const tags = await git.tags();
|
||||||
return tags.all;
|
return tags.all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCommitHashForRefName = async ({
|
||||||
|
path,
|
||||||
|
refName,
|
||||||
|
}: {
|
||||||
|
path: string,
|
||||||
|
refName: string,
|
||||||
|
}) => {
|
||||||
|
const git = createGitClientForPath(path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The `^{commit}` suffix is used to fully dereference the ref to a commit hash.
|
||||||
|
const rev = await git.revparse(`${refName}^{commit}`);
|
||||||
|
return rev;
|
||||||
|
|
||||||
|
// @note: Was hitting errors when the repository is empty,
|
||||||
|
// so we're catching the error and returning undefined.
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,12 +2,12 @@ import "./instrument.js";
|
||||||
|
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { hasEntitlement, loadConfig } from '@sourcebot/shared';
|
import { getConfigSettings, hasEntitlement } from '@sourcebot/shared';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { ConnectionManager } from './connectionManager.js';
|
import { ConnectionManager } from './connectionManager.js';
|
||||||
import { DEFAULT_SETTINGS, INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js';
|
import { INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js';
|
||||||
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
|
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
|
||||||
import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
||||||
import { GithubAppManager } from "./ee/githubAppManager.js";
|
import { GithubAppManager } from "./ee/githubAppManager.js";
|
||||||
|
|
@ -18,20 +18,6 @@ import { PromClient } from './promClient.js';
|
||||||
|
|
||||||
const logger = createLogger('backend-entrypoint');
|
const logger = createLogger('backend-entrypoint');
|
||||||
|
|
||||||
const getSettings = async (configPath?: string) => {
|
|
||||||
if (!configPath) {
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await loadConfig(configPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...DEFAULT_SETTINGS,
|
|
||||||
...config.settings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const reposPath = REPOS_CACHE_DIR;
|
const reposPath = REPOS_CACHE_DIR;
|
||||||
const indexPath = INDEX_CACHE_DIR;
|
const indexPath = INDEX_CACHE_DIR;
|
||||||
|
|
||||||
|
|
@ -57,8 +43,7 @@ redis.ping().then(() => {
|
||||||
|
|
||||||
const promClient = new PromClient();
|
const promClient = new PromClient();
|
||||||
|
|
||||||
const settings = await getSettings(env.CONFIG_PATH);
|
const settings = await getConfigSettings(env.CONFIG_PATH);
|
||||||
|
|
||||||
|
|
||||||
if (hasEntitlement('github-app')) {
|
if (hasEntitlement('github-app')) {
|
||||||
await GithubAppManager.getInstance().init(prisma);
|
await GithubAppManager.getInstance().init(prisma);
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ import { marshalBool } from "./utils.js";
|
||||||
import { createLogger } from '@sourcebot/logger';
|
import { createLogger } from '@sourcebot/logger';
|
||||||
import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||||
import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
|
import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
|
||||||
import { RepoMetadata } from './types.js';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { glob } from 'glob';
|
import { glob } from 'glob';
|
||||||
import { getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js';
|
import { getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import GitUrlParse from 'git-url-parse';
|
import GitUrlParse from 'git-url-parse';
|
||||||
|
import { RepoMetadata } from '@sourcebot/shared';
|
||||||
|
|
||||||
export type RepoData = WithRequired<Prisma.RepoCreateInput, 'connections'>;
|
export type RepoData = WithRequired<Prisma.RepoCreateInput, 'connections'>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
|
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
|
||||||
import { createLogger, Logger } from "@sourcebot/logger";
|
import { createLogger, Logger } from "@sourcebot/logger";
|
||||||
|
import { repoMetadataSchema, RepoIndexingJobMetadata, repoIndexingJobMetadataSchema, RepoMetadata } from '@sourcebot/shared';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { readdir, rm } from 'fs/promises';
|
import { readdir, rm } from 'fs/promises';
|
||||||
import { Job, Queue, ReservedJob, Worker } from "groupmq";
|
import { Job, Queue, ReservedJob, Worker } from "groupmq";
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
|
import micromatch from 'micromatch';
|
||||||
import { INDEX_CACHE_DIR } from './constants.js';
|
import { INDEX_CACHE_DIR } from './constants.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { cloneRepository, fetchRepository, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js';
|
import { cloneRepository, fetchRepository, getBranches, getCommitHashForRefName, getTags, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js';
|
||||||
|
import { captureEvent } from './posthog.js';
|
||||||
import { PromClient } from './promClient.js';
|
import { PromClient } from './promClient.js';
|
||||||
import { repoMetadataSchema, RepoWithConnections, Settings } from "./types.js";
|
import { RepoWithConnections, Settings } from "./types.js";
|
||||||
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js';
|
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js';
|
||||||
import { indexGitRepository } from './zoekt.js';
|
import { indexGitRepository } from './zoekt.js';
|
||||||
|
|
||||||
|
|
@ -61,7 +64,7 @@ export class RepoIndexManager {
|
||||||
concurrency: this.settings.maxRepoIndexingJobConcurrency,
|
concurrency: this.settings.maxRepoIndexingJobConcurrency,
|
||||||
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
|
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
|
||||||
logger: true,
|
logger: true,
|
||||||
}: {}),
|
} : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.worker.on('completed', this.onJobCompleted.bind(this));
|
this.worker.on('completed', this.onJobCompleted.bind(this));
|
||||||
|
|
@ -126,7 +129,7 @@ export class RepoIndexManager {
|
||||||
{
|
{
|
||||||
AND: [
|
AND: [
|
||||||
{ status: RepoIndexingJobStatus.FAILED },
|
{ status: RepoIndexingJobStatus.FAILED },
|
||||||
{ completedAt: { gt: timeoutDate } },
|
{ completedAt: { gt: thresholdDate } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -263,7 +266,16 @@ export class RepoIndexManager {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (jobType === RepoIndexingJobType.INDEX) {
|
if (jobType === RepoIndexingJobType.INDEX) {
|
||||||
await this.indexRepository(repo, logger, abortController.signal);
|
const revisions = await this.indexRepository(repo, logger, abortController.signal);
|
||||||
|
|
||||||
|
await this.db.repoIndexingJob.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
metadata: {
|
||||||
|
indexedRevisions: revisions,
|
||||||
|
} satisfies RepoIndexingJobMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else if (jobType === RepoIndexingJobType.CLEANUP) {
|
} else if (jobType === RepoIndexingJobType.CLEANUP) {
|
||||||
await this.cleanupRepository(repo, logger);
|
await this.cleanupRepository(repo, logger);
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +297,7 @@ export class RepoIndexManager {
|
||||||
// If the repo path exists but it is not a valid git repository root, this indicates
|
// If the repo path exists but it is not a valid git repository root, this indicates
|
||||||
// that the repository is in a bad state. To fix, we remove the directory and perform
|
// that the repository is in a bad state. To fix, we remove the directory and perform
|
||||||
// a fresh clone.
|
// a fresh clone.
|
||||||
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot( { path: repoPath } ))) {
|
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot({ path: repoPath }))) {
|
||||||
const isValidGitRepo = await isPathAValidGitRepoRoot({
|
const isValidGitRepo = await isPathAValidGitRepoRoot({
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -354,10 +366,54 @@ export class RepoIndexManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let revisions = [
|
||||||
|
'HEAD'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (metadata.branches) {
|
||||||
|
const branchGlobs = metadata.branches
|
||||||
|
const allBranches = await getBranches(repoPath);
|
||||||
|
const matchingBranches =
|
||||||
|
allBranches
|
||||||
|
.filter((branch) => micromatch.isMatch(branch, branchGlobs))
|
||||||
|
.map((branch) => `refs/heads/${branch}`);
|
||||||
|
|
||||||
|
revisions = [
|
||||||
|
...revisions,
|
||||||
|
...matchingBranches
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.tags) {
|
||||||
|
const tagGlobs = metadata.tags;
|
||||||
|
const allTags = await getTags(repoPath);
|
||||||
|
const matchingTags =
|
||||||
|
allTags
|
||||||
|
.filter((tag) => micromatch.isMatch(tag, tagGlobs))
|
||||||
|
.map((tag) => `refs/tags/${tag}`);
|
||||||
|
|
||||||
|
revisions = [
|
||||||
|
...revisions,
|
||||||
|
...matchingTags
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoekt has a limit of 64 branches/tags to index.
|
||||||
|
if (revisions.length > 64) {
|
||||||
|
logger.warn(`Too many revisions (${revisions.length}) for repo ${repo.id}, truncating to 64`);
|
||||||
|
captureEvent('backend_revisions_truncated', {
|
||||||
|
repoId: repo.id,
|
||||||
|
revisionCount: revisions.length,
|
||||||
|
});
|
||||||
|
revisions = revisions.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Indexing ${repo.name} (id: ${repo.id})...`);
|
logger.info(`Indexing ${repo.name} (id: ${repo.id})...`);
|
||||||
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, signal));
|
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, revisions, signal));
|
||||||
const indexDuration_s = durationMs / 1000;
|
const indexDuration_s = durationMs / 1000;
|
||||||
logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`);
|
logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`);
|
||||||
|
|
||||||
|
return revisions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupRepository(repo: Repo, logger: Logger) {
|
private async cleanupRepository(repo: Repo, logger: Logger) {
|
||||||
|
|
@ -384,16 +440,32 @@ export class RepoIndexManager {
|
||||||
data: {
|
data: {
|
||||||
status: RepoIndexingJobStatus.COMPLETED,
|
status: RepoIndexingJobStatus.COMPLETED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
repo: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobTypeLabel = getJobTypePrometheusLabel(jobData.type);
|
const jobTypeLabel = getJobTypePrometheusLabel(jobData.type);
|
||||||
|
|
||||||
if (jobData.type === RepoIndexingJobType.INDEX) {
|
if (jobData.type === RepoIndexingJobType.INDEX) {
|
||||||
|
const { path: repoPath } = getRepoPath(jobData.repo);
|
||||||
|
const commitHash = await getCommitHashForRefName({
|
||||||
|
path: repoPath,
|
||||||
|
refName: 'HEAD',
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobMetadata = repoIndexingJobMetadataSchema.parse(jobData.metadata);
|
||||||
|
|
||||||
const repo = await this.db.repo.update({
|
const repo = await this.db.repo.update({
|
||||||
where: { id: jobData.repoId },
|
where: { id: jobData.repoId },
|
||||||
data: {
|
data: {
|
||||||
indexedAt: new Date(),
|
indexedAt: new Date(),
|
||||||
|
indexedCommitHash: commitHash,
|
||||||
|
metadata: {
|
||||||
|
...(jobData.repo.metadata as RepoMetadata),
|
||||||
|
indexedRevisions: jobMetadata.indexedRevisions,
|
||||||
|
} satisfies RepoMetadata,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,8 @@
|
||||||
import { Connection, Repo, RepoToConnection } from "@sourcebot/db";
|
import { Connection, Repo, RepoToConnection } from "@sourcebot/db";
|
||||||
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export type Settings = Required<SettingsSchema>;
|
export type Settings = Required<SettingsSchema>;
|
||||||
|
|
||||||
// Structure of the `metadata` field in the `Repo` table.
|
|
||||||
//
|
|
||||||
// @WARNING: If you modify this schema, please make sure it is backwards
|
|
||||||
// compatible with any prior versions of the schema!!
|
|
||||||
// @NOTE: If you move this schema, please update the comment in schema.prisma
|
|
||||||
// to point to the new location.
|
|
||||||
export const repoMetadataSchema = z.object({
|
|
||||||
/**
|
|
||||||
* A set of key-value pairs that will be used as git config
|
|
||||||
* variables when cloning the repo.
|
|
||||||
* @see: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--configcodecodeltkeygtltvaluegtcode
|
|
||||||
*/
|
|
||||||
gitConfig: z.record(z.string(), z.string()).optional(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of branches to index. Glob patterns are supported.
|
|
||||||
*/
|
|
||||||
branches: z.array(z.string()).optional(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of tags to index. Glob patterns are supported.
|
|
||||||
*/
|
|
||||||
tags: z.array(z.string()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RepoMetadata = z.infer<typeof repoMetadataSchema>;
|
|
||||||
|
|
||||||
// @see : https://stackoverflow.com/a/61132308
|
// @see : https://stackoverflow.com/a/61132308
|
||||||
export type DeepPartial<T> = T extends object ? {
|
export type DeepPartial<T> = T extends object ? {
|
||||||
[P in keyof T]?: DeepPartial<T[P]>;
|
[P in keyof T]?: DeepPartial<T[P]>;
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,16 @@
|
||||||
import { Repo } from "@sourcebot/db";
|
import { Repo } from "@sourcebot/db";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import micromatch from "micromatch";
|
|
||||||
import { INDEX_CACHE_DIR } from "./constants.js";
|
import { INDEX_CACHE_DIR } from "./constants.js";
|
||||||
import { getBranches, getTags } from "./git.js";
|
import { Settings } from "./types.js";
|
||||||
import { captureEvent } from "./posthog.js";
|
|
||||||
import { repoMetadataSchema, Settings } from "./types.js";
|
|
||||||
import { getRepoPath, getShardPrefix } from "./utils.js";
|
import { getRepoPath, getShardPrefix } from "./utils.js";
|
||||||
|
|
||||||
const logger = createLogger('zoekt');
|
const logger = createLogger('zoekt');
|
||||||
|
|
||||||
export const indexGitRepository = async (repo: Repo, settings: Settings, signal?: AbortSignal) => {
|
export const indexGitRepository = async (repo: Repo, settings: Settings, revisions: string[], signal?: AbortSignal) => {
|
||||||
let revisions = [
|
|
||||||
'HEAD'
|
|
||||||
];
|
|
||||||
|
|
||||||
const { path: repoPath } = getRepoPath(repo);
|
const { path: repoPath } = getRepoPath(repo);
|
||||||
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
||||||
const metadata = repoMetadataSchema.parse(repo.metadata);
|
|
||||||
|
|
||||||
if (metadata.branches) {
|
|
||||||
const branchGlobs = metadata.branches
|
|
||||||
const allBranches = await getBranches(repoPath);
|
|
||||||
const matchingBranches =
|
|
||||||
allBranches
|
|
||||||
.filter((branch) => micromatch.isMatch(branch, branchGlobs))
|
|
||||||
.map((branch) => `refs/heads/${branch}`);
|
|
||||||
|
|
||||||
revisions = [
|
|
||||||
...revisions,
|
|
||||||
...matchingBranches
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.tags) {
|
|
||||||
const tagGlobs = metadata.tags;
|
|
||||||
const allTags = await getTags(repoPath);
|
|
||||||
const matchingTags =
|
|
||||||
allTags
|
|
||||||
.filter((tag) => micromatch.isMatch(tag, tagGlobs))
|
|
||||||
.map((tag) => `refs/tags/${tag}`);
|
|
||||||
|
|
||||||
revisions = [
|
|
||||||
...revisions,
|
|
||||||
...matchingTags
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// zoekt has a limit of 64 branches/tags to index.
|
|
||||||
if (revisions.length > 64) {
|
|
||||||
logger.warn(`Too many revisions (${revisions.length}) for repo ${repo.id}, truncating to 64`);
|
|
||||||
captureEvent('backend_revisions_truncated', {
|
|
||||||
repoId: repo.id,
|
|
||||||
revisionCount: revisions.length,
|
|
||||||
});
|
|
||||||
revisions = revisions.slice(0, 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = [
|
const command = [
|
||||||
'zoekt-git-index',
|
'zoekt-git-index',
|
||||||
'-allow_missing_branches',
|
'-allow_missing_branches',
|
||||||
|
|
@ -76,7 +30,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, signal?
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
stdout.split('\n').filter(line => line.trim()).forEach(line => {
|
stdout.split('\n').filter(line => line.trim()).forEach(line => {
|
||||||
logger.info(line);
|
logger.info(line);
|
||||||
|
|
@ -89,7 +43,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, signal?
|
||||||
logger.info(line);
|
logger.info(line);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
stdout,
|
stdout,
|
||||||
stderr
|
stderr
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Repo" ADD COLUMN "indexedCommitHash" TEXT;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RepoIndexingJob" ADD COLUMN "metadata" JSONB;
|
||||||
|
|
@ -38,7 +38,7 @@ model Repo {
|
||||||
isFork Boolean
|
isFork Boolean
|
||||||
isArchived Boolean
|
isArchived Boolean
|
||||||
isPublic Boolean @default(false)
|
isPublic Boolean @default(false)
|
||||||
metadata Json /// For schema see repoMetadataSchema in packages/backend/src/types.ts
|
metadata Json /// For schema see repoMetadataSchema in packages/shared/src/types.ts
|
||||||
cloneUrl String
|
cloneUrl String
|
||||||
webUrl String?
|
webUrl String?
|
||||||
connections RepoToConnection[]
|
connections RepoToConnection[]
|
||||||
|
|
@ -50,6 +50,7 @@ model Repo {
|
||||||
|
|
||||||
jobs RepoIndexingJob[]
|
jobs RepoIndexingJob[]
|
||||||
indexedAt DateTime? /// When the repo was last indexed successfully.
|
indexedAt DateTime? /// When the repo was last indexed successfully.
|
||||||
|
indexedCommitHash String? /// The commit hash of the last indexed commit (on HEAD).
|
||||||
|
|
||||||
external_id String /// The id of the repo in the external service
|
external_id String /// The id of the repo in the external service
|
||||||
external_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.)
|
external_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.)
|
||||||
|
|
@ -83,6 +84,7 @@ model RepoIndexingJob {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
metadata Json? /// For schema see repoIndexingJobMetadataSchema in packages/shared/src/types.ts
|
||||||
|
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ConfigSettings } from "./types.js";
|
||||||
|
|
||||||
export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';
|
export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';
|
||||||
|
|
||||||
|
|
@ -8,4 +9,24 @@ export const SOURCEBOT_CLOUD_ENVIRONMENT = [
|
||||||
"prod",
|
"prod",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SOURCEBOT_UNLIMITED_SEATS = -1;
|
export const SOURCEBOT_UNLIMITED_SEATS = -1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default settings.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CONFIG_SETTINGS: ConfigSettings = {
|
||||||
|
maxFileSize: 2 * 1024 * 1024, // 2MB in bytes
|
||||||
|
maxTrigramCount: 20000,
|
||||||
|
reindexIntervalMs: 1000 * 60 * 60, // 1 hour
|
||||||
|
resyncConnectionIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
resyncConnectionPollingIntervalMs: 1000 * 1, // 1 second
|
||||||
|
reindexRepoPollingIntervalMs: 1000 * 1, // 1 second
|
||||||
|
maxConnectionSyncJobConcurrency: 8,
|
||||||
|
maxRepoIndexingJobConcurrency: 8,
|
||||||
|
maxRepoGarbageCollectionJobConcurrency: 8,
|
||||||
|
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
|
||||||
|
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
|
||||||
|
enablePublicAccess: false, // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
|
||||||
|
experiment_repoDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
experiment_userDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,20 @@ export type {
|
||||||
Plan,
|
Plan,
|
||||||
Entitlement,
|
Entitlement,
|
||||||
} from "./entitlements.js";
|
} from "./entitlements.js";
|
||||||
|
export type {
|
||||||
|
RepoMetadata,
|
||||||
|
RepoIndexingJobMetadata,
|
||||||
|
} from "./types.js";
|
||||||
|
export {
|
||||||
|
repoMetadataSchema,
|
||||||
|
repoIndexingJobMetadataSchema,
|
||||||
|
} from "./types.js";
|
||||||
export {
|
export {
|
||||||
base64Decode,
|
base64Decode,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
loadJsonFile,
|
loadJsonFile,
|
||||||
isRemotePath,
|
isRemotePath,
|
||||||
|
getConfigSettings,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
export {
|
export {
|
||||||
syncSearchContexts,
|
syncSearchContexts,
|
||||||
|
|
|
||||||
45
packages/shared/src/types.ts
Normal file
45
packages/shared/src/types.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type ConfigSettings = Required<SettingsSchema>;
|
||||||
|
|
||||||
|
// Structure of the `metadata` field in the `Repo` table.
|
||||||
|
//
|
||||||
|
// @WARNING: If you modify this schema, please make sure it is backwards
|
||||||
|
// compatible with any prior versions of the schema!!
|
||||||
|
// @NOTE: If you move this schema, please update the comment in schema.prisma
|
||||||
|
// to point to the new location.
|
||||||
|
export const repoMetadataSchema = z.object({
|
||||||
|
/**
|
||||||
|
* A set of key-value pairs that will be used as git config
|
||||||
|
* variables when cloning the repo.
|
||||||
|
* @see: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--configcodecodeltkeygtltvaluegtcode
|
||||||
|
*/
|
||||||
|
gitConfig: z.record(z.string(), z.string()).optional(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of branches to index. Glob patterns are supported.
|
||||||
|
*/
|
||||||
|
branches: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of tags to index. Glob patterns are supported.
|
||||||
|
*/
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of revisions that were indexed for the repo.
|
||||||
|
*/
|
||||||
|
indexedRevisions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RepoMetadata = z.infer<typeof repoMetadataSchema>;
|
||||||
|
|
||||||
|
export const repoIndexingJobMetadataSchema = z.object({
|
||||||
|
/**
|
||||||
|
* A list of revisions that were indexed for the repo.
|
||||||
|
*/
|
||||||
|
indexedRevisions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RepoIndexingJobMetadata = z.infer<typeof repoIndexingJobMetadataSchema>;
|
||||||
|
|
@ -4,6 +4,8 @@ import { readFile } from 'fs/promises';
|
||||||
import stripJsonComments from 'strip-json-comments';
|
import stripJsonComments from 'strip-json-comments';
|
||||||
import { Ajv } from "ajv";
|
import { Ajv } from "ajv";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { DEFAULT_CONFIG_SETTINGS } from "./constants.js";
|
||||||
|
import { ConfigSettings } from "./types.js";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
|
|
@ -130,3 +132,16 @@ export const loadConfig = async (configPath: string): Promise<SourcebotConfig> =
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getConfigSettings = async (configPath?: string): Promise<ConfigSettings> => {
|
||||||
|
if (!configPath) {
|
||||||
|
return DEFAULT_CONFIG_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG_SETTINGS,
|
||||||
|
...config.settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/web/src/app/[domain]/components/DisplayDate.tsx
Normal file
36
packages/web/src/app/[domain]/components/DisplayDate.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { getFormattedDate } from "@/lib/utils"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
const formatFullDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
timeZoneName: "short",
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisplayDateProps {
|
||||||
|
date: Date
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisplayDate = ({ date, className }: DisplayDateProps) => {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={className}>
|
||||||
|
{getFormattedDate(date)}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{formatFullDate(date)}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
withTopMargin?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Header = ({
|
|
||||||
children,
|
|
||||||
withTopMargin = true,
|
|
||||||
className,
|
|
||||||
}: HeaderProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn("mb-16", className)}>
|
|
||||||
{children}
|
|
||||||
<Separator className={clsx("absolute left-0 right-0", { "mt-12": withTopMargin })} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
VscSymbolVariable
|
VscSymbolVariable
|
||||||
} from "react-icons/vsc";
|
} from "react-icons/vsc";
|
||||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||||
import { getDisplayTime, isServiceError, unwrapServiceError } from "@/lib/utils";
|
import { getFormattedDate, isServiceError, unwrapServiceError } from "@/lib/utils";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,7 +139,7 @@ export const useSuggestionsData = ({
|
||||||
const searchHistorySuggestions = useMemo(() => {
|
const searchHistorySuggestions = useMemo(() => {
|
||||||
return searchHistory.map(search => ({
|
return searchHistory.map(search => ({
|
||||||
value: search.query,
|
value: search.query,
|
||||||
description: getDisplayTime(new Date(search.date)),
|
description: getFormattedDate(new Date(search.date)),
|
||||||
} satisfies Suggestion));
|
} satisfies Suggestion));
|
||||||
}, [searchHistory]);
|
}, [searchHistory]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItemNoItemText, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -87,7 +87,7 @@ export const SearchModeSelector = ({
|
||||||
onMouseEnter={() => setFocusedSearchMode("precise")}
|
onMouseEnter={() => setFocusedSearchMode("precise")}
|
||||||
onFocus={() => setFocusedSearchMode("precise")}
|
onFocus={() => setFocusedSearchMode("precise")}
|
||||||
>
|
>
|
||||||
<SelectItem
|
<SelectItemNoItemText
|
||||||
value="precise"
|
value="precise"
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|
@ -99,7 +99,7 @@ export const SearchModeSelector = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</SelectItem>
|
</SelectItemNoItemText>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="right"
|
side="right"
|
||||||
className="w-64 z-50"
|
className="w-64 z-50"
|
||||||
|
|
@ -126,7 +126,7 @@ export const SearchModeSelector = ({
|
||||||
onMouseEnter={() => setFocusedSearchMode("agentic")}
|
onMouseEnter={() => setFocusedSearchMode("agentic")}
|
||||||
onFocus={() => setFocusedSearchMode("agentic")}
|
onFocus={() => setFocusedSearchMode("agentic")}
|
||||||
>
|
>
|
||||||
<SelectItem
|
<SelectItemNoItemText
|
||||||
value="agentic"
|
value="agentic"
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|
@ -138,7 +138,7 @@ export const SearchModeSelector = ({
|
||||||
<KeyboardShortcutHint shortcut="⌘ I" />
|
<KeyboardShortcutHint shortcut="⌘ I" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItemNoItemText>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -167,5 +167,3 @@ export const SearchModeSelector = ({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
206
packages/web/src/app/[domain]/repos/[id]/page.tsx
Normal file
206
packages/web/src/app/[domain]/repos/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { sew } from "@/actions"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError"
|
||||||
|
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"
|
||||||
|
import { withOptionalAuthV2 } from "@/withAuthV2"
|
||||||
|
import { ChevronLeft, ExternalLink, Info } from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { RepoJobsTable } from "../components/repoJobsTable"
|
||||||
|
import { getConfigSettings } from "@sourcebot/shared"
|
||||||
|
import { env } from "@/env.mjs"
|
||||||
|
import { DisplayDate } from "../../components/DisplayDate"
|
||||||
|
import { RepoBranchesTable } from "../components/repoBranchesTable"
|
||||||
|
import { repoMetadataSchema } from "@sourcebot/shared"
|
||||||
|
|
||||||
|
export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
const repo = await getRepoWithJobs(Number.parseInt(id))
|
||||||
|
if (isServiceError(repo)) {
|
||||||
|
throw new ServiceErrorException(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeHostInfo = getCodeHostInfoForRepo({
|
||||||
|
codeHostType: repo.external_codeHostType,
|
||||||
|
name: repo.name,
|
||||||
|
displayName: repo.displayName ?? undefined,
|
||||||
|
webUrl: repo.webUrl ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configSettings = await getConfigSettings(env.CONFIG_PATH);
|
||||||
|
|
||||||
|
const nextIndexAttempt = (() => {
|
||||||
|
const latestJob = repo.jobs.length > 0 ? repo.jobs[0] : null;
|
||||||
|
if (!latestJob) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestJob.completedAt) {
|
||||||
|
return new Date(latestJob.completedAt.getTime() + configSettings.reindexIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const repoMetadata = repoMetadataSchema.parse(repo.metadata);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" asChild className="mb-4">
|
||||||
|
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos`}>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to repositories
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">{repo.displayName || repo.name}</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">{repo.name}</p>
|
||||||
|
</div>
|
||||||
|
{(codeHostInfo && codeHostInfo.repoLink) && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={codeHostInfo.repoLink} target="_blank" rel="noopener noreferrer" className="flex items-center">
|
||||||
|
<Image
|
||||||
|
src={codeHostInfo.icon}
|
||||||
|
alt={codeHostInfo.codeHostName}
|
||||||
|
className={cn("w-4 h-4 flex-shrink-0", codeHostInfo.iconClassName)}
|
||||||
|
/>
|
||||||
|
Open in {codeHostInfo.codeHostName}
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
{repo.isArchived && <Badge variant="secondary">Archived</Badge>}
|
||||||
|
{repo.isPublic && <Badge variant="outline">Public</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Created
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>When this repository was first added to Sourcebot</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DisplayDate date={repo.createdAt} className="text-2xl font-semibold" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Last indexed
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>The last time this repository was successfully indexed</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{repo.indexedAt ? <DisplayDate date={repo.indexedAt} className="text-2xl font-semibold" /> : "Never"}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Scheduled
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>When the next indexing job is scheduled to run</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{nextIndexAttempt ? <DisplayDate date={nextIndexAttempt} className="text-2xl font-semibold" /> : "-"}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{repoMetadata.indexedRevisions && (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle>Indexed Branches</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Branches that have been indexed for this repository. <Link href="https://docs.sourcebot.dev/docs/features/search/multi-branch-indexing" target="_blank" className="text-link hover:underline">Docs</Link></CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||||
|
<RepoBranchesTable
|
||||||
|
indexRevisions={repoMetadata.indexedRevisions}
|
||||||
|
repoWebUrl={repo.webUrl}
|
||||||
|
repoCodeHostType={repo.external_codeHostType}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Indexing Jobs</CardTitle>
|
||||||
|
<CardDescription>History of all indexing and cleanup jobs for this repository.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
|
||||||
|
<RepoJobsTable data={repo.jobs} />
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRepoWithJobs = async (repoId: number) => sew(() =>
|
||||||
|
withOptionalAuthV2(async ({ prisma }) => {
|
||||||
|
|
||||||
|
const repo = await prisma.repo.findUnique({
|
||||||
|
where: {
|
||||||
|
id: repoId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
jobs: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { ArrowUpDown, Clock, Loader2, CheckCircle2, Check, ListFilter } from "lucide-react"
|
|
||||||
import Image from "next/image"
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
import { cn, getRepoImageSrc } from "@/lib/utils"
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { getBrowsePath } from "../browse/hooks/utils"
|
|
||||||
|
|
||||||
export type RepoStatus = 'syncing' | 'indexed' | 'not-indexed';
|
|
||||||
|
|
||||||
export type RepositoryColumnInfo = {
|
|
||||||
repoId: number
|
|
||||||
repoName: string;
|
|
||||||
repoDisplayName: string
|
|
||||||
imageUrl?: string
|
|
||||||
status: RepoStatus
|
|
||||||
lastIndexed: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabels: Record<RepoStatus, string> = {
|
|
||||||
'syncing': "Syncing",
|
|
||||||
'indexed': "Indexed",
|
|
||||||
'not-indexed': "Pending",
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatusIndicator = ({ status }: { status: RepoStatus }) => {
|
|
||||||
let icon = null
|
|
||||||
let description = ""
|
|
||||||
let className = ""
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case 'syncing':
|
|
||||||
icon = <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
description = "Repository is currently syncing"
|
|
||||||
className = "text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400"
|
|
||||||
break
|
|
||||||
case 'indexed':
|
|
||||||
icon = <CheckCircle2 className="h-3.5 w-3.5" />
|
|
||||||
description = "Repository has been successfully indexed and is up to date"
|
|
||||||
className = "text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400"
|
|
||||||
break
|
|
||||||
case 'not-indexed':
|
|
||||||
icon = <Clock className="h-3.5 w-3.5" />
|
|
||||||
description = "Repository is pending initial sync"
|
|
||||||
className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={cn("flex items-center gap-1.5 text-xs font-medium px-2.5 py-0.5 rounded-full w-fit", className)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{statusLabels[status]}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="text-sm">{description}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
|
||||||
{
|
|
||||||
accessorKey: "repoDisplayName",
|
|
||||||
header: 'Repository',
|
|
||||||
size: 500,
|
|
||||||
cell: ({ row: { original: { repoId, repoName, repoDisplayName, imageUrl } } }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center gap-3 py-2">
|
|
||||||
<div className="relative h-8 w-8 overflow-hidden rounded-md border bg-muted">
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
src={getRepoImageSrc(imageUrl, repoId, domain) || "/placeholder.svg"}
|
|
||||||
alt={`${repoDisplayName} logo`}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center bg-muted text-xs font-medium uppercase text-muted-foreground">
|
|
||||||
{repoDisplayName.charAt(0)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
className={"font-medium text-primary hover:underline cursor-pointer"}
|
|
||||||
href={getBrowsePath({
|
|
||||||
repoName: repoName,
|
|
||||||
path: '/',
|
|
||||||
pathType: 'tree',
|
|
||||||
domain
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{repoDisplayName.length > 40 ? `${repoDisplayName.slice(0, 40)}...` : repoDisplayName}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
size: 150,
|
|
||||||
header: ({ column }) => {
|
|
||||||
const uniqueLabels = Object.values(statusLabels);
|
|
||||||
const currentFilter = column.getFilterValue() as string | undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[150px]">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"px-0 font-medium hover:bg-transparent focus:bg-transparent active:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
||||||
currentFilter ? "text-primary hover:text-primary" : "text-muted-foreground hover:text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
<ListFilter className={cn(
|
|
||||||
"ml-2 h-3.5 w-3.5",
|
|
||||||
currentFilter ? "text-primary" : "text-muted-foreground"
|
|
||||||
)} />
|
|
||||||
{currentFilter && (
|
|
||||||
<div className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-primary animate-pulse" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={() => column.setFilterValue(undefined)}>
|
|
||||||
<Check className={cn("mr-2 h-4 w-4", !column.getFilterValue() ? "opacity-100" : "opacity-0")} />
|
|
||||||
All
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{uniqueLabels.map((label) => (
|
|
||||||
<DropdownMenuItem key={label} onClick={() => column.setFilterValue(label)}>
|
|
||||||
<Check className={cn("mr-2 h-4 w-4", column.getFilterValue() === label ? "opacity-100" : "opacity-0")} />
|
|
||||||
{label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <StatusIndicator status={row.original.status} />
|
|
||||||
},
|
|
||||||
filterFn: (row, id, value) => {
|
|
||||||
if (value === undefined) return true;
|
|
||||||
|
|
||||||
const status = row.getValue(id) as RepoStatus;
|
|
||||||
return statusLabels[status] === value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "lastIndexed",
|
|
||||||
size: 150,
|
|
||||||
header: ({ column }) => (
|
|
||||||
<div className="w-[150px]">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="px-0 font-medium hover:bg-transparent focus:bg-transparent active:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
||||||
>
|
|
||||||
Last Synced
|
|
||||||
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
if (!row.original.lastIndexed) {
|
|
||||||
return <div className="text-muted-foreground">Never</div>;
|
|
||||||
}
|
|
||||||
const date = new Date(row.original.lastIndexed)
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{date
|
|
||||||
.toLocaleTimeString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
.toLowerCase()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { CodeHostType, getCodeHostBrowseAtBranchUrl } from "@/lib/utils"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
type RepoBranchesTableProps = {
|
||||||
|
indexRevisions: string[];
|
||||||
|
repoWebUrl: string | null;
|
||||||
|
repoCodeHostType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepoBranchesTable = ({ indexRevisions, repoWebUrl, repoCodeHostType }: RepoBranchesTableProps) => {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const columns = React.useMemo<ColumnDef<string>[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "refName",
|
||||||
|
header: "Revision",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const refName = row.original;
|
||||||
|
const shortRefName = refName.replace(/^refs\/(heads|tags)\//, "");
|
||||||
|
|
||||||
|
const branchUrl = getCodeHostBrowseAtBranchUrl({
|
||||||
|
webUrl: repoWebUrl,
|
||||||
|
codeHostType: repoCodeHostType as CodeHostType,
|
||||||
|
branchName: refName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return branchUrl ? (
|
||||||
|
<Link
|
||||||
|
href={branchUrl}
|
||||||
|
className="font-mono text-sm text-link hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{shortRefName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm text-muted-foreground"
|
||||||
|
title="This revision is not indexed"
|
||||||
|
>
|
||||||
|
{shortRefName}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, [repoCodeHostType, repoWebUrl]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: indexRevisions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter branches..."
|
||||||
|
value={(table.getColumn("refName")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) => table.getColumn("refName")?.setFilterValue(event.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No branches found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
320
packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx
Normal file
320
packages/web/src/app/[domain]/repos/components/repoJobsTable.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { AlertCircle, ArrowUpDown, RefreshCwIcon } from "lucide-react"
|
||||||
|
import * as React from "react"
|
||||||
|
import { CopyIconButton } from "../../components/copyIconButton"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { LightweightCodeHighlighter } from "../../components/lightweightCodeHighlighter"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
import { DisplayDate } from "../../components/DisplayDate"
|
||||||
|
|
||||||
|
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
|
||||||
|
|
||||||
|
export type RepoIndexingJob = {
|
||||||
|
id: string
|
||||||
|
type: "INDEX" | "CLEANUP"
|
||||||
|
status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED"
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
completedAt: Date | null
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadgeVariants = cva("", {
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
COMPLETED: "bg-green-600 text-white hover:bg-green-700",
|
||||||
|
FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusBadge = (status: RepoIndexingJob["status"]) => {
|
||||||
|
const labels = {
|
||||||
|
PENDING: "Pending",
|
||||||
|
IN_PROGRESS: "In Progress",
|
||||||
|
COMPLETED: "Completed",
|
||||||
|
FAILED: "Failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeBadge = (type: RepoIndexingJob["type"]) => {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDuration = (start: Date, end: Date | null) => {
|
||||||
|
if (!end) return "-"
|
||||||
|
const diff = end.getTime() - start.getTime()
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const seconds = Math.floor((diff % 60000) / 1000)
|
||||||
|
return `${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns: ColumnDef<RepoIndexingJob>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
header: "Type",
|
||||||
|
cell: ({ row }) => getTypeBadge(row.getValue("type")),
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const job = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge(row.getValue("status"))}
|
||||||
|
{job.errorMessage && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[750px] max-h-96 overflow-scroll">
|
||||||
|
<LightweightCodeHighlighter
|
||||||
|
language="text"
|
||||||
|
lineNumbers={true}
|
||||||
|
renderWhitespace={false}
|
||||||
|
>
|
||||||
|
{job.errorMessage}
|
||||||
|
</LightweightCodeHighlighter>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
Started
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => <DisplayDate date={row.getValue("createdAt") as Date} className="ml-3"/>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "completedAt",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
Completed
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const completedAt = row.getValue("completedAt") as Date | null;
|
||||||
|
if (!completedAt) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DisplayDate date={completedAt} className="ml-3"/>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "duration",
|
||||||
|
header: "Duration",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const job = row.original
|
||||||
|
return getDuration(job.createdAt, job.completedAt)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "Job ID",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const id = row.getValue("id") as string
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs text-muted-foreground">{id}</code>
|
||||||
|
<CopyIconButton onCopy={() => {
|
||||||
|
navigator.clipboard.writeText(id);
|
||||||
|
return true;
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const RepoJobsTable = ({ data }: { data: RepoIndexingJob[] }) => {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([{ id: "createdAt", desc: true }])
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
numCompleted,
|
||||||
|
numInProgress,
|
||||||
|
numPending,
|
||||||
|
numFailed,
|
||||||
|
} = useMemo(() => {
|
||||||
|
return {
|
||||||
|
numCompleted: data.filter((job) => job.status === "COMPLETED").length,
|
||||||
|
numInProgress: data.filter((job) => job.status === "IN_PROGRESS").length,
|
||||||
|
numPending: data.filter((job) => job.status === "PENDING").length,
|
||||||
|
numFailed: data.filter((job) => job.status === "FAILED").length,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-4 py-4">
|
||||||
|
<Select
|
||||||
|
value={(table.getColumn("status")?.getFilterValue() as string) ?? "all"}
|
||||||
|
onValueChange={(value) => table.getColumn("status")?.setFilterValue(value === "all" ? "" : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Filter by status</SelectItem>
|
||||||
|
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
|
||||||
|
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
|
||||||
|
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
|
||||||
|
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={(table.getColumn("type")?.getFilterValue() as string) ?? "all"}
|
||||||
|
onValueChange={(value) => table.getColumn("type")?.setFilterValue(value === "all" ? "" : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All types</SelectItem>
|
||||||
|
<SelectItem value="INDEX">Index</SelectItem>
|
||||||
|
<SelectItem value="CLEANUP">Cleanup</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
router.refresh();
|
||||||
|
toast({
|
||||||
|
description: "Page refreshed",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="w-3 h-3" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No indexing jobs found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredRowModel().rows.length} job(s) total
|
||||||
|
</div>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
395
packages/web/src/app/[domain]/repos/components/reposTable.tsx
Normal file
395
packages/web/src/app/[domain]/repos/components/reposTable.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
||||||
|
import { CodeHostType, getCodeHostCommitUrl, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ArrowUpDown, ExternalLink, MoreHorizontal, RefreshCwIcon } from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { getBrowsePath } from "../../browse/hooks/utils"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { DisplayDate } from "../../components/DisplayDate"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
|
||||||
|
|
||||||
|
export type Repo = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
displayName: string | null
|
||||||
|
isArchived: boolean
|
||||||
|
isPublic: boolean
|
||||||
|
indexedAt: Date | null
|
||||||
|
createdAt: Date
|
||||||
|
webUrl: string | null
|
||||||
|
codeHostType: string
|
||||||
|
imageUrl: string | null
|
||||||
|
indexedCommitHash: string | null
|
||||||
|
latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadgeVariants = cva("", {
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
COMPLETED: "bg-green-600 text-white hover:bg-green-700",
|
||||||
|
FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
NO_JOBS: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusBadge = (status: Repo["latestJobStatus"]) => {
|
||||||
|
if (!status) {
|
||||||
|
return <Badge className={statusBadgeVariants({ status: "NO_JOBS" })}>No Jobs</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
PENDING: "Pending",
|
||||||
|
IN_PROGRESS: "In Progress",
|
||||||
|
COMPLETED: "Completed",
|
||||||
|
FAILED: "Failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns: ColumnDef<Repo>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "displayName",
|
||||||
|
size: 400,
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
Repository
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const repo = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
{repo.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={getRepoImageSrc(repo.imageUrl, repo.id) || "/placeholder.svg"}
|
||||||
|
alt={`${repo.displayName} logo`}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-muted text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{repo.displayName?.charAt(0) ?? repo.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link href={getBrowsePath({
|
||||||
|
repoName: repo.name,
|
||||||
|
path: '/',
|
||||||
|
pathType: 'tree',
|
||||||
|
domain: SINGLE_TENANT_ORG_DOMAIN,
|
||||||
|
})} className="font-medium hover:underline">
|
||||||
|
{repo.displayName || repo.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "latestJobStatus",
|
||||||
|
size: 150,
|
||||||
|
header: "Lastest status",
|
||||||
|
cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "indexedAt",
|
||||||
|
size: 200,
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Last synced
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const indexedAt = row.getValue("indexedAt") as Date | null;
|
||||||
|
if (!indexedAt) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisplayDate date={indexedAt} className="ml-3"/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "indexedCommitHash",
|
||||||
|
size: 150,
|
||||||
|
header: "Synced commit",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hash = row.getValue("indexedCommitHash") as string | null;
|
||||||
|
if (!hash) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallHash = hash.slice(0, 7);
|
||||||
|
const repo = row.original;
|
||||||
|
const codeHostType = repo.codeHostType as CodeHostType;
|
||||||
|
const webUrl = repo.webUrl;
|
||||||
|
|
||||||
|
const commitUrl = getCodeHostCommitUrl({
|
||||||
|
webUrl,
|
||||||
|
codeHostType,
|
||||||
|
commitHash: hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HashComponent = commitUrl ? (
|
||||||
|
<Link
|
||||||
|
href={commitUrl}
|
||||||
|
className="font-mono text-sm text-link hover:underline"
|
||||||
|
>
|
||||||
|
{smallHash}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-sm text-muted-foreground">
|
||||||
|
{smallHash}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{HashComponent}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<span className="font-mono">{hash}</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
size: 80,
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const repo = row.original
|
||||||
|
const codeHostInfo = getCodeHostInfoForRepo({
|
||||||
|
codeHostType: repo.codeHostType,
|
||||||
|
name: repo.name,
|
||||||
|
displayName: repo.displayName ?? undefined,
|
||||||
|
webUrl: repo.webUrl ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repo.id}`}>View details</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{(repo.webUrl && codeHostInfo) && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href={repo.webUrl} target="_blank" rel="noopener noreferrer" className="flex items-center">
|
||||||
|
Open in {codeHostInfo.codeHostName}
|
||||||
|
<ExternalLink className="ml-2 h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ReposTable = ({ data }: { data: Repo[] }) => {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
numCompleted,
|
||||||
|
numInProgress,
|
||||||
|
numPending,
|
||||||
|
numFailed,
|
||||||
|
numNoJobs,
|
||||||
|
} = useMemo(() => {
|
||||||
|
return {
|
||||||
|
numCompleted: data.filter((repo) => repo.latestJobStatus === "COMPLETED").length,
|
||||||
|
numInProgress: data.filter((repo) => repo.latestJobStatus === "IN_PROGRESS").length,
|
||||||
|
numPending: data.filter((repo) => repo.latestJobStatus === "PENDING").length,
|
||||||
|
numFailed: data.filter((repo) => repo.latestJobStatus === "FAILED").length,
|
||||||
|
numNoJobs: data.filter((repo) => repo.latestJobStatus === null).length,
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
enableColumnResizing: false,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-4 py-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter repositories..."
|
||||||
|
value={(table.getColumn("displayName")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) => table.getColumn("displayName")?.setFilterValue(event.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={(table.getColumn("latestJobStatus")?.getFilterValue() as string) ?? "all"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.getColumn("latestJobStatus")?.setFilterValue(value === "all" ? "" : value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Filter by status</SelectItem>
|
||||||
|
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
|
||||||
|
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
|
||||||
|
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
|
||||||
|
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
|
||||||
|
<SelectItem value="null">No status ({numNoJobs})</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
router.refresh();
|
||||||
|
toast({
|
||||||
|
description: "Page refreshed",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="w-3 h-3" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table style={{ tableLayout: 'fixed', width: '100%' }}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
style={{ width: `${header.getSize()}px` }}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{ width: `${cell.column.getSize()}px` }}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredRowModel().rows.length} {data.length > 1 ? 'repositories' : 'repository'} total
|
||||||
|
</div>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,65 +1,54 @@
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { RepoIndexingJob } from "@sourcebot/db";
|
|
||||||
import { Header } from "../components/header";
|
|
||||||
import { RepoStatus } from "./columns";
|
|
||||||
import { RepositoryTable } from "./repositoryTable";
|
|
||||||
import { sew } from "@/actions";
|
import { sew } from "@/actions";
|
||||||
import { withOptionalAuthV2 } from "@/withAuthV2";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||||
|
import { ReposTable } from "./components/reposTable";
|
||||||
|
|
||||||
function getRepoStatus(repo: { indexedAt: Date | null, jobs: RepoIndexingJob[] }): RepoStatus {
|
export default async function ReposPage() {
|
||||||
const latestJob = repo.jobs[0];
|
|
||||||
|
|
||||||
if (latestJob?.status === 'PENDING' || latestJob?.status === 'IN_PROGRESS') {
|
|
||||||
return 'syncing';
|
|
||||||
}
|
|
||||||
|
|
||||||
return repo.indexedAt ? 'indexed' : 'not-indexed';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ReposPage(props: { params: Promise<{ domain: string }> }) {
|
const repos = await getReposWithLatestJob();
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const {
|
|
||||||
domain
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
const repos = await getReposWithJobs();
|
|
||||||
if (isServiceError(repos)) {
|
if (isServiceError(repos)) {
|
||||||
throw new ServiceErrorException(repos);
|
throw new ServiceErrorException(repos);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="container mx-auto">
|
||||||
<Header>
|
<div className="mb-6">
|
||||||
<h1 className="text-3xl">Repositories</h1>
|
<h1 className="text-3xl font-semibold">Repositories</h1>
|
||||||
</Header>
|
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
|
||||||
<div className="px-6 py-6">
|
|
||||||
<RepositoryTable
|
|
||||||
repos={repos.map((repo) => ({
|
|
||||||
repoId: repo.id,
|
|
||||||
repoName: repo.name,
|
|
||||||
repoDisplayName: repo.displayName ?? repo.name,
|
|
||||||
imageUrl: repo.imageUrl ?? undefined,
|
|
||||||
indexedAt: repo.indexedAt ?? undefined,
|
|
||||||
status: getRepoStatus(repo),
|
|
||||||
}))}
|
|
||||||
domain={domain}
|
|
||||||
isAddReposButtonVisible={env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED === 'true'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ReposTable data={repos.map((repo) => ({
|
||||||
|
id: repo.id,
|
||||||
|
name: repo.name,
|
||||||
|
displayName: repo.displayName ?? repo.name,
|
||||||
|
isArchived: repo.isArchived,
|
||||||
|
isPublic: repo.isPublic,
|
||||||
|
indexedAt: repo.indexedAt,
|
||||||
|
createdAt: repo.createdAt,
|
||||||
|
webUrl: repo.webUrl,
|
||||||
|
imageUrl: repo.imageUrl,
|
||||||
|
latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null,
|
||||||
|
codeHostType: repo.external_codeHostType,
|
||||||
|
indexedCommitHash: repo.indexedCommitHash,
|
||||||
|
}))} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposWithJobs = async () => sew(() =>
|
const getReposWithLatestJob = async () => sew(() =>
|
||||||
withOptionalAuthV2(async ({ prisma }) => {
|
withOptionalAuthV2(async ({ prisma }) => {
|
||||||
const repos = await prisma.repo.findMany({
|
const repos = await prisma.repo.findMany({
|
||||||
include: {
|
include: {
|
||||||
jobs: true,
|
jobs: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
},
|
||||||
|
take: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return repos;
|
return repos;
|
||||||
}));
|
}));
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
|
||||||
import { PlusIcon, RefreshCwIcon } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { columns, RepositoryColumnInfo, RepoStatus } from "./columns";
|
|
||||||
import { AddRepositoryDialog } from "./components/addRepositoryDialog";
|
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
|
||||||
repos: {
|
|
||||||
repoId: number;
|
|
||||||
repoName: string;
|
|
||||||
repoDisplayName: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
indexedAt?: Date;
|
|
||||||
status: RepoStatus;
|
|
||||||
}[];
|
|
||||||
domain: string;
|
|
||||||
isAddReposButtonVisible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepositoryTable = ({
|
|
||||||
repos,
|
|
||||||
domain,
|
|
||||||
isAddReposButtonVisible,
|
|
||||||
}: RepositoryTableProps) => {
|
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const tableRepos = useMemo(() => {
|
|
||||||
return repos.map((repo): RepositoryColumnInfo => ({
|
|
||||||
repoId: repo.repoId,
|
|
||||||
repoName: repo.repoName,
|
|
||||||
repoDisplayName: repo.repoDisplayName ?? repo.repoName,
|
|
||||||
imageUrl: repo.imageUrl,
|
|
||||||
status: repo.status,
|
|
||||||
lastIndexed: repo.indexedAt?.toISOString() ?? "",
|
|
||||||
})).sort((a, b) => {
|
|
||||||
const getPriorityFromStatus = (status: RepoStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'syncing':
|
|
||||||
return 0 // Highest priority - currently syncing
|
|
||||||
case 'not-indexed':
|
|
||||||
return 1 // Second priority - not yet indexed
|
|
||||||
case 'indexed':
|
|
||||||
return 2 // Third priority - successfully indexed
|
|
||||||
default:
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by priority first
|
|
||||||
const aPriority = getPriorityFromStatus(a.status);
|
|
||||||
const bPriority = getPriorityFromStatus(b.status);
|
|
||||||
|
|
||||||
if (aPriority !== bPriority) {
|
|
||||||
return aPriority - bPriority;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If same priority, sort by last indexed date (most recent first)
|
|
||||||
if (a.lastIndexed && b.lastIndexed) {
|
|
||||||
return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put items without dates at the end
|
|
||||||
if (!a.lastIndexed) return 1;
|
|
||||||
if (!b.lastIndexed) return -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}, [repos]);
|
|
||||||
|
|
||||||
const tableColumns = useMemo(() => {
|
|
||||||
return columns(domain);
|
|
||||||
}, [domain]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
data={tableRepos}
|
|
||||||
searchKey="repoDisplayName"
|
|
||||||
searchPlaceholder="Search repositories..."
|
|
||||||
headerActions={(
|
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
className="ml-2"
|
|
||||||
onClick={() => {
|
|
||||||
router.refresh();
|
|
||||||
toast({
|
|
||||||
description: "Page refreshed",
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<RefreshCwIcon className="w-4 h-4" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
{isAddReposButtonVisible && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
onClick={() => setIsAddDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
|
||||||
Add repository
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddRepositoryDialog
|
|
||||||
isOpen={isAddDialogOpen}
|
|
||||||
onOpenChange={setIsAddDialogOpen}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { LucideKeyRound, MoreVertical, Search, LucideTrash } from "lucide-react";
|
import { LucideKeyRound, MoreVertical, Search, LucideTrash } from "lucide-react";
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { getDisplayTime, isServiceError } from "@/lib/utils";
|
import { getFormattedDate, isServiceError } from "@/lib/utils";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
|
|
@ -104,7 +104,7 @@ export const SecretsList = ({ secrets }: SecretsListProps) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Created {getDisplayTime(secret.createdAt)}
|
Created {getFormattedDate(secret.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -129,11 +129,34 @@ const SelectItem = React.forwardRef<
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{children}
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
))
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectItemNoItemText = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItemNoItemText.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
|
@ -154,6 +177,7 @@ export {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectLabel,
|
SelectLabel,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectItemNoItemText,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { ErrorCode } from "./errorCodes";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { Org } from "@sourcebot/db";
|
import { Org } from "@sourcebot/db";
|
||||||
import { OrgMetadata, orgMetadataSchema } from "@/types";
|
import { OrgMetadata, orgMetadataSchema } from "@/types";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "./constants";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
|
|
@ -319,6 +320,70 @@ export const getCodeHostIcon = (codeHostType: string): { src: string, className?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getCodeHostCommitUrl = ({
|
||||||
|
webUrl,
|
||||||
|
codeHostType,
|
||||||
|
commitHash,
|
||||||
|
}: {
|
||||||
|
webUrl?: string | null,
|
||||||
|
codeHostType: CodeHostType,
|
||||||
|
commitHash: string,
|
||||||
|
}) => {
|
||||||
|
if (!webUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (codeHostType) {
|
||||||
|
case 'github':
|
||||||
|
return `${webUrl}/commit/${commitHash}`;
|
||||||
|
case 'gitlab':
|
||||||
|
return `${webUrl}/-/commit/${commitHash}`;
|
||||||
|
case 'gitea':
|
||||||
|
return `${webUrl}/commit/${commitHash}`;
|
||||||
|
case 'azuredevops':
|
||||||
|
return `${webUrl}/commit/${commitHash}`;
|
||||||
|
case 'bitbucket-cloud':
|
||||||
|
return `${webUrl}/commits/${commitHash}`;
|
||||||
|
case 'bitbucket-server':
|
||||||
|
return `${webUrl}/commits/${commitHash}`;
|
||||||
|
case 'gerrit':
|
||||||
|
case 'generic-git-host':
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCodeHostBrowseAtBranchUrl = ({
|
||||||
|
webUrl,
|
||||||
|
codeHostType,
|
||||||
|
branchName,
|
||||||
|
}: {
|
||||||
|
webUrl?: string | null,
|
||||||
|
codeHostType: CodeHostType,
|
||||||
|
branchName: string,
|
||||||
|
}) => {
|
||||||
|
if (!webUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (codeHostType) {
|
||||||
|
case 'github':
|
||||||
|
return `${webUrl}/tree/${branchName}`;
|
||||||
|
case 'gitlab':
|
||||||
|
return `${webUrl}/-/tree/${branchName}`;
|
||||||
|
case 'gitea':
|
||||||
|
return `${webUrl}/src/branch/${branchName}`;
|
||||||
|
case 'azuredevops':
|
||||||
|
return `${webUrl}?branch=${branchName}`;
|
||||||
|
case 'bitbucket-cloud':
|
||||||
|
return `${webUrl}?at=${branchName}`;
|
||||||
|
case 'bitbucket-server':
|
||||||
|
return `${webUrl}?at=${branchName}`;
|
||||||
|
case 'gerrit':
|
||||||
|
case 'generic-git-host':
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean => {
|
export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean => {
|
||||||
switch (codeHostType) {
|
switch (codeHostType) {
|
||||||
case "github":
|
case "github":
|
||||||
|
|
@ -347,32 +412,38 @@ export const isDefined = <T>(arg: T | null | undefined): arg is T extends null |
|
||||||
return arg !== null && arg !== undefined;
|
return arg !== null && arg !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDisplayTime = (date: Date) => {
|
export const getFormattedDate = (date: Date) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const minutes = (now.getTime() - date.getTime()) / (1000 * 60);
|
const diffMinutes = (now.getTime() - date.getTime()) / (1000 * 60);
|
||||||
|
const isFuture = diffMinutes < 0;
|
||||||
|
|
||||||
|
// Use absolute values for calculations
|
||||||
|
const minutes = Math.abs(diffMinutes);
|
||||||
const hours = minutes / 60;
|
const hours = minutes / 60;
|
||||||
const days = hours / 24;
|
const days = hours / 24;
|
||||||
const months = days / 30;
|
const months = days / 30;
|
||||||
|
|
||||||
const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => {
|
const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month', isFuture: boolean) => {
|
||||||
const roundedValue = Math.floor(value);
|
const roundedValue = Math.floor(value);
|
||||||
if (roundedValue < 2) {
|
const pluralUnit = roundedValue === 1 ? unit : `${unit}s`;
|
||||||
return `${roundedValue} ${unit} ago`;
|
|
||||||
|
if (isFuture) {
|
||||||
|
return `In ${roundedValue} ${pluralUnit}`;
|
||||||
} else {
|
} else {
|
||||||
return `${roundedValue} ${unit}s ago`;
|
return `${roundedValue} ${pluralUnit} ago`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (minutes < 1) {
|
if (minutes < 1) {
|
||||||
return 'just now';
|
return 'just now';
|
||||||
} else if (minutes < 60) {
|
} else if (minutes < 60) {
|
||||||
return formatTime(minutes, 'minute');
|
return formatTime(minutes, 'minute', isFuture);
|
||||||
} else if (hours < 24) {
|
} else if (hours < 24) {
|
||||||
return formatTime(hours, 'hour');
|
return formatTime(hours, 'hour', isFuture);
|
||||||
} else if (days < 30) {
|
} else if (days < 30) {
|
||||||
return formatTime(days, 'day');
|
return formatTime(days, 'day', isFuture);
|
||||||
} else {
|
} else {
|
||||||
return formatTime(months, 'month');
|
return formatTime(months, 'month', isFuture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,7 +529,7 @@ export const requiredQueryParamGuard = (request: NextRequest, param: string): Se
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, domain: string): string | undefined => {
|
export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number): string | undefined => {
|
||||||
if (!imageUrl) return undefined;
|
if (!imageUrl) return undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -478,7 +549,7 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, do
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
} else {
|
} else {
|
||||||
// Use the proxied route for self-hosted instances
|
// Use the proxied route for self-hosted instances
|
||||||
return `/api/${domain}/repos/${repoId}/image`;
|
return `/api/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repoId}/image`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If URL parsing fails, use the original URL
|
// If URL parsing fails, use the original URL
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue