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)
|
||||
- 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 repositories table. [#572](https://github.com/sourcebot-dev/sourcebot/pull/572)
|
||||
|
||||
### Removed
|
||||
- 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 { Settings } from "./types.js";
|
||||
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 = [
|
||||
'github',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -269,3 +269,25 @@ export const getTags = async (path: string) => {
|
|||
const tags = await git.tags();
|
||||
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 { createLogger } from "@sourcebot/logger";
|
||||
import { hasEntitlement, loadConfig } from '@sourcebot/shared';
|
||||
import { getConfigSettings, hasEntitlement } from '@sourcebot/shared';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { Redis } from 'ioredis';
|
||||
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 { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
||||
import { GithubAppManager } from "./ee/githubAppManager.js";
|
||||
|
|
@ -18,20 +18,6 @@ import { PromClient } from './promClient.js';
|
|||
|
||||
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 indexPath = INDEX_CACHE_DIR;
|
||||
|
||||
|
|
@ -57,8 +43,7 @@ redis.ping().then(() => {
|
|||
|
||||
const promClient = new PromClient();
|
||||
|
||||
const settings = await getSettings(env.CONFIG_PATH);
|
||||
|
||||
const settings = await getConfigSettings(env.CONFIG_PATH);
|
||||
|
||||
if (hasEntitlement('github-app')) {
|
||||
await GithubAppManager.getInstance().init(prisma);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import { marshalBool } from "./utils.js";
|
|||
import { createLogger } from '@sourcebot/logger';
|
||||
import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||
import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
|
||||
import { RepoMetadata } from './types.js';
|
||||
import path from 'path';
|
||||
import { glob } from 'glob';
|
||||
import { getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js';
|
||||
import assert from 'assert';
|
||||
import GitUrlParse from 'git-url-parse';
|
||||
import { RepoMetadata } from '@sourcebot/shared';
|
||||
|
||||
export type RepoData = WithRequired<Prisma.RepoCreateInput, 'connections'>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import * as Sentry from '@sentry/node';
|
||||
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
|
||||
import { createLogger, Logger } from "@sourcebot/logger";
|
||||
import { repoMetadataSchema, RepoIndexingJobMetadata, repoIndexingJobMetadataSchema, RepoMetadata } from '@sourcebot/shared';
|
||||
import { existsSync } from 'fs';
|
||||
import { readdir, rm } from 'fs/promises';
|
||||
import { Job, Queue, ReservedJob, Worker } from "groupmq";
|
||||
import { Redis } from 'ioredis';
|
||||
import micromatch from 'micromatch';
|
||||
import { INDEX_CACHE_DIR } from './constants.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 { repoMetadataSchema, RepoWithConnections, Settings } from "./types.js";
|
||||
import { RepoWithConnections, Settings } from "./types.js";
|
||||
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js';
|
||||
import { indexGitRepository } from './zoekt.js';
|
||||
|
||||
|
|
@ -61,7 +64,7 @@ export class RepoIndexManager {
|
|||
concurrency: this.settings.maxRepoIndexingJobConcurrency,
|
||||
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
|
||||
logger: true,
|
||||
}: {}),
|
||||
} : {}),
|
||||
});
|
||||
|
||||
this.worker.on('completed', this.onJobCompleted.bind(this));
|
||||
|
|
@ -126,7 +129,7 @@ export class RepoIndexManager {
|
|||
{
|
||||
AND: [
|
||||
{ status: RepoIndexingJobStatus.FAILED },
|
||||
{ completedAt: { gt: timeoutDate } },
|
||||
{ completedAt: { gt: thresholdDate } },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -263,7 +266,16 @@ export class RepoIndexManager {
|
|||
|
||||
try {
|
||||
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) {
|
||||
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
|
||||
// that the repository is in a bad state. To fix, we remove the directory and perform
|
||||
// a fresh clone.
|
||||
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot( { path: repoPath } ))) {
|
||||
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot({ path: repoPath }))) {
|
||||
const isValidGitRepo = await isPathAValidGitRepoRoot({
|
||||
path: repoPath,
|
||||
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})...`);
|
||||
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, signal));
|
||||
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, revisions, signal));
|
||||
const indexDuration_s = durationMs / 1000;
|
||||
logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`);
|
||||
|
||||
return revisions;
|
||||
}
|
||||
|
||||
private async cleanupRepository(repo: Repo, logger: Logger) {
|
||||
|
|
@ -384,16 +440,32 @@ export class RepoIndexManager {
|
|||
data: {
|
||||
status: RepoIndexingJobStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
repo: true,
|
||||
}
|
||||
});
|
||||
|
||||
const jobTypeLabel = getJobTypePrometheusLabel(jobData.type);
|
||||
|
||||
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({
|
||||
where: { id: jobData.repoId },
|
||||
data: {
|
||||
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 { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
||||
import { z } from "zod";
|
||||
|
||||
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
|
||||
export type DeepPartial<T> = T extends object ? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
|
|
|
|||
|
|
@ -1,61 +1,15 @@
|
|||
import { Repo } from "@sourcebot/db";
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
import { exec } from "child_process";
|
||||
import micromatch from "micromatch";
|
||||
import { INDEX_CACHE_DIR } from "./constants.js";
|
||||
import { getBranches, getTags } from "./git.js";
|
||||
import { captureEvent } from "./posthog.js";
|
||||
import { repoMetadataSchema, Settings } from "./types.js";
|
||||
import { Settings } from "./types.js";
|
||||
import { getRepoPath, getShardPrefix } from "./utils.js";
|
||||
|
||||
const logger = createLogger('zoekt');
|
||||
|
||||
export const indexGitRepository = async (repo: Repo, settings: Settings, signal?: AbortSignal) => {
|
||||
let revisions = [
|
||||
'HEAD'
|
||||
];
|
||||
|
||||
export const indexGitRepository = async (repo: Repo, settings: Settings, revisions: string[], signal?: AbortSignal) => {
|
||||
const { path: repoPath } = getRepoPath(repo);
|
||||
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 = [
|
||||
'zoekt-git-index',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
isArchived Boolean
|
||||
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
|
||||
webUrl String?
|
||||
connections RepoToConnection[]
|
||||
|
|
@ -50,6 +50,7 @@ model Repo {
|
|||
|
||||
jobs RepoIndexingJob[]
|
||||
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_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.)
|
||||
|
|
@ -83,6 +84,7 @@ model RepoIndexingJob {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
completedAt DateTime?
|
||||
metadata Json? /// For schema see repoIndexingJobMetadataSchema in packages/shared/src/types.ts
|
||||
|
||||
errorMessage String?
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ConfigSettings } from "./types.js";
|
||||
|
||||
export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';
|
||||
|
||||
|
|
@ -9,3 +10,23 @@ export const SOURCEBOT_CLOUD_ENVIRONMENT = [
|
|||
] as const;
|
||||
|
||||
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,
|
||||
Entitlement,
|
||||
} from "./entitlements.js";
|
||||
export type {
|
||||
RepoMetadata,
|
||||
RepoIndexingJobMetadata,
|
||||
} from "./types.js";
|
||||
export {
|
||||
repoMetadataSchema,
|
||||
repoIndexingJobMetadataSchema,
|
||||
} from "./types.js";
|
||||
export {
|
||||
base64Decode,
|
||||
loadConfig,
|
||||
loadJsonFile,
|
||||
isRemotePath,
|
||||
getConfigSettings,
|
||||
} from "./utils.js";
|
||||
export {
|
||||
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 { Ajv } from "ajv";
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_CONFIG_SETTINGS } from "./constants.js";
|
||||
import { ConfigSettings } from "./types.js";
|
||||
|
||||
const ajv = new Ajv({
|
||||
validateFormats: false,
|
||||
|
|
@ -130,3 +132,16 @@ export const loadConfig = async (configPath: string): Promise<SourcebotConfig> =
|
|||
}
|
||||
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
|
||||
} from "react-icons/vsc";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
import { getDisplayTime, isServiceError, unwrapServiceError } from "@/lib/utils";
|
||||
import { getFormattedDate, isServiceError, unwrapServiceError } from "@/lib/utils";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ export const useSuggestionsData = ({
|
|||
const searchHistorySuggestions = useMemo(() => {
|
||||
return searchHistory.map(search => ({
|
||||
value: search.query,
|
||||
description: getDisplayTime(new Date(search.date)),
|
||||
description: getFormattedDate(new Date(search.date)),
|
||||
} satisfies Suggestion));
|
||||
}, [searchHistory]);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -87,7 +87,7 @@ export const SearchModeSelector = ({
|
|||
onMouseEnter={() => setFocusedSearchMode("precise")}
|
||||
onFocus={() => setFocusedSearchMode("precise")}
|
||||
>
|
||||
<SelectItem
|
||||
<SelectItemNoItemText
|
||||
value="precise"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
|
|
@ -99,7 +99,7 @@ export const SearchModeSelector = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
</SelectItem>
|
||||
</SelectItemNoItemText>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="w-64 z-50"
|
||||
|
|
@ -126,7 +126,7 @@ export const SearchModeSelector = ({
|
|||
onMouseEnter={() => setFocusedSearchMode("agentic")}
|
||||
onFocus={() => setFocusedSearchMode("agentic")}
|
||||
>
|
||||
<SelectItem
|
||||
<SelectItemNoItemText
|
||||
value="agentic"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
|
|
@ -138,7 +138,7 @@ export const SearchModeSelector = ({
|
|||
<KeyboardShortcutHint shortcut="⌘ I" />
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItemNoItemText>
|
||||
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -167,5 +167,3 @@ export const SearchModeSelector = ({
|
|||
</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 { withOptionalAuthV2 } from "@/withAuthV2";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
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 {
|
||||
const latestJob = repo.jobs[0];
|
||||
export default async function ReposPage() {
|
||||
|
||||
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 params = await props.params;
|
||||
|
||||
const {
|
||||
domain
|
||||
} = params;
|
||||
|
||||
const repos = await getReposWithJobs();
|
||||
const repos = await getReposWithLatestJob();
|
||||
if (isServiceError(repos)) {
|
||||
throw new ServiceErrorException(repos);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header>
|
||||
<h1 className="text-3xl">Repositories</h1>
|
||||
</Header>
|
||||
<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 className="container mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-semibold">Repositories</h1>
|
||||
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
const getReposWithJobs = async () => sew(() =>
|
||||
const getReposWithLatestJob = async () => sew(() =>
|
||||
withOptionalAuthV2(async ({ prisma }) => {
|
||||
const repos = await prisma.repo.findMany({
|
||||
include: {
|
||||
jobs: true,
|
||||
jobs: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 1
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
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 { useState, useMemo, useCallback } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 className="flex items-center gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created {getDisplayTime(secret.createdAt)}
|
||||
Created {getFormattedDate(secret.createdAt)}
|
||||
</p>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -129,11 +129,34 @@ const SelectItem = React.forwardRef<
|
|||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
{children}
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
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<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
|
|
@ -154,6 +177,7 @@ export {
|
|||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectItemNoItemText,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { ErrorCode } from "./errorCodes";
|
|||
import { NextRequest } from "next/server";
|
||||
import { Org } from "@sourcebot/db";
|
||||
import { OrgMetadata, orgMetadataSchema } from "@/types";
|
||||
import { SINGLE_TENANT_ORG_DOMAIN } from "./constants";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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 => {
|
||||
switch (codeHostType) {
|
||||
case "github":
|
||||
|
|
@ -347,32 +412,38 @@ export const isDefined = <T>(arg: T | null | undefined): arg is T extends null |
|
|||
return arg !== null && arg !== undefined;
|
||||
}
|
||||
|
||||
export const getDisplayTime = (date: Date) => {
|
||||
export const getFormattedDate = (date: 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 days = hours / 24;
|
||||
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);
|
||||
if (roundedValue < 2) {
|
||||
return `${roundedValue} ${unit} ago`;
|
||||
const pluralUnit = roundedValue === 1 ? unit : `${unit}s`;
|
||||
|
||||
if (isFuture) {
|
||||
return `In ${roundedValue} ${pluralUnit}`;
|
||||
} else {
|
||||
return `${roundedValue} ${unit}s ago`;
|
||||
return `${roundedValue} ${pluralUnit} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
if (minutes < 1) {
|
||||
return 'just now';
|
||||
} else if (minutes < 60) {
|
||||
return formatTime(minutes, 'minute');
|
||||
return formatTime(minutes, 'minute', isFuture);
|
||||
} else if (hours < 24) {
|
||||
return formatTime(hours, 'hour');
|
||||
return formatTime(hours, 'hour', isFuture);
|
||||
} else if (days < 30) {
|
||||
return formatTime(days, 'day');
|
||||
return formatTime(days, 'day', isFuture);
|
||||
} else {
|
||||
return formatTime(months, 'month');
|
||||
return formatTime(months, 'month', isFuture);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -458,7 +529,7 @@ export const requiredQueryParamGuard = (request: NextRequest, param: string): Se
|
|||
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;
|
||||
|
||||
try {
|
||||
|
|
@ -478,7 +549,7 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, do
|
|||
return imageUrl;
|
||||
} else {
|
||||
// Use the proxied route for self-hosted instances
|
||||
return `/api/${domain}/repos/${repoId}/image`;
|
||||
return `/api/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repoId}/image`;
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, use the original URL
|
||||
|
|
|
|||
Loading…
Reference in a new issue