feat(web): Improved repository table (#572)
Some checks are pending
Publish to ghcr / build (linux/amd64, blacksmith-4vcpu-ubuntu-2404) (push) Waiting to run
Publish to ghcr / build (linux/arm64, blacksmith-8vcpu-ubuntu-2204-arm) (push) Waiting to run
Publish to ghcr / merge (push) Blocked by required conditions

This commit is contained in:
Brendan Kellam 2025-10-25 14:51:41 -04:00 committed by GitHub
parent 4b86bcd182
commit 2d3b03bf12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1456 additions and 547 deletions

View file

@ -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)

View file

@ -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',
];

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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'>;

View file

@ -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,
}
});

View file

@ -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]>;

View file

@ -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',

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Repo" ADD COLUMN "indexedCommitHash" TEXT;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RepoIndexingJob" ADD COLUMN "metadata" JSONB;

View file

@ -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?

View file

@ -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
}

View file

@ -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,

View 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>;

View file

@ -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,
}
}

View 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>
)
}

View file

@ -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>
)
}

View file

@ -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]);

View file

@ -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>
)
}

View 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;
})
);

View file

@ -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>
)
},
},
]

View file

@ -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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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;
}));

View file

@ -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}
/>
</>
);
}

View file

@ -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>

View file

@ -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,

View file

@ -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