diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c363645..1f20fd50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added a live repository indexing job runtime to the syncing badge. [#1623](https://github.com/sourcebot-dev/sourcebot/pull/1623) + ## [5.1.10] - 2026-08-27 ### Fixed diff --git a/packages/shared/src/bullmqClient.test.ts b/packages/shared/src/bullmqClient.test.ts index 72ed1918e..f373cf5b5 100644 --- a/packages/shared/src/bullmqClient.test.ts +++ b/packages/shared/src/bullmqClient.test.ts @@ -50,6 +50,7 @@ describe("BullMQClient", () => { return { id: jobId, data: { connectionId: 1 }, + processedOn: 900_000, failedReason: "", returnvalue: null, getState: vi.fn(async () => "active"), @@ -59,6 +60,7 @@ describe("BullMQClient", () => { return { id: jobId, data: { connectionId: 2 }, + processedOn: 800_000, failedReason: "", returnvalue: { outcome: "SUCCESS" }, getState: vi.fn(async () => "completed"), @@ -75,6 +77,7 @@ describe("BullMQClient", () => { id: "job-1", data: { connectionId: 1 }, status: "IN_PROGRESS", + startedAt: 900_000, errorMessage: null, result: null, }], @@ -83,12 +86,36 @@ describe("BullMQClient", () => { id: "job-2", data: { connectionId: 2 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "SUCCESS" }, }], ])); }); + test("does not expose a stale start time for a pending job", async () => { + mocks.getJob.mockResolvedValue({ + id: "job-1", + data: { connectionId: 1 }, + processedOn: 900_000, + failedReason: "", + returnvalue: null, + getState: vi.fn(async () => "waiting"), + }); + const client = new BullMQClient({} as Redis); + + await expect( + client.getJob(CONNECTION_QUEUE, "job-1"), + ).resolves.toEqual({ + id: "job-1", + data: { connectionId: 1 }, + status: "PENDING", + startedAt: null, + errorMessage: null, + result: null, + }); + }); + test("returns null for an unrecognized legacy connection result", async () => { mocks.getJob.mockResolvedValue({ id: "job-1", @@ -108,6 +135,7 @@ describe("BullMQClient", () => { id: "job-1", data: { connectionId: 1 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }); diff --git a/packages/shared/src/bullmqClient.ts b/packages/shared/src/bullmqClient.ts index 0562543ad..19178f752 100644 --- a/packages/shared/src/bullmqClient.ts +++ b/packages/shared/src/bullmqClient.ts @@ -24,6 +24,7 @@ export interface WorkloadJob { id: string; data: DataOf; status: WorkloadJobStatus; + startedAt: number | null; errorMessage: string | null; result: ResultOf | null; } @@ -100,6 +101,10 @@ export class BullMQClient { id: job.id ?? jobId, data: job.data as DataOf, status, + startedAt: status === "IN_PROGRESS" + && typeof job.processedOn === "number" + ? job.processedOn + : null, errorMessage: status === "FAILED" ? job.failedReason || null : null, result, }; diff --git a/packages/web/src/app/(app)/repos/components/reposTable.test.tsx b/packages/web/src/app/(app)/repos/components/reposTable.test.tsx index 5ed70c23c..050ec36e7 100644 --- a/packages/web/src/app/(app)/repos/components/reposTable.test.tsx +++ b/packages/web/src/app/(app)/repos/components/reposTable.test.tsx @@ -42,6 +42,7 @@ const repos: Repo[] = [ id: "job-1", data: { repoId: 1 }, status: "IN_PROGRESS", + startedAt: Date.now() - 30_000, errorMessage: null, result: null, }, @@ -222,6 +223,7 @@ describe("ReposTable", () => { id: "completed-job", data: { repoId: 2 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }, @@ -288,7 +290,7 @@ describe("ReposTable", () => { await waitFor(() => { expect(reposActions.indexRepo).toHaveBeenCalledWith(2); - expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("Pending")).toBeTruthy(); expect(fetch).toHaveBeenCalledOnce(); }); @@ -311,6 +313,7 @@ describe("ReposTable", () => { id: "active-reindex-job", data: { repoId: repos[1].id }, status: "IN_PROGRESS", + startedAt: Date.now() - 30_000, errorMessage: null, result: null, }, @@ -348,6 +351,7 @@ describe("ReposTable", () => { id: "first-interactive-job", data: { repoId: 1 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }, @@ -392,6 +396,7 @@ describe("ReposTable", () => { id: "warning-job", data: { repoId: 2 }, status: "FAILED", + startedAt: null, errorMessage: "The remote repository could not be reached", result: null, }, @@ -518,6 +523,7 @@ describe("ReposTable", () => { id: "job-1", data: { repoId: 1 }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed while cloning", result: null, }, @@ -540,7 +546,7 @@ describe("ReposTable", () => { await waitFor(() => { expect(reposActions.indexRepo).toHaveBeenCalledWith(1); - expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("Pending")).toBeTruthy(); expect(fetch).toHaveBeenCalledOnce(); }); expect(screen.queryByText("Repository sync failed")).toBeNull(); @@ -570,6 +576,7 @@ describe("ReposTable", () => { id: "job-1", data: { repoId: 1 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }, @@ -600,7 +607,7 @@ describe("ReposTable", () => { expect(navigation.refresh).not.toHaveBeenCalled(); }); - test("polls a syncing repository whose latest job is missing", async () => { + test("polls a pending repository whose latest job is missing", async () => { const response: RepoIndexingStatusesResponse = { repositories: [{ repoId: 1, @@ -610,6 +617,7 @@ describe("ReposTable", () => { id: "job-1", data: { repoId: 1 }, status: "FAILED", + startedAt: null, errorMessage: "Indexing failed", result: null, }, @@ -619,10 +627,10 @@ describe("ReposTable", () => { renderTable([{ ...repos[0], latestJob: null }]); - expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("Pending")).toBeTruthy(); await waitFor(() => expect(fetch).toHaveBeenCalledOnce()); await waitFor(() => expect(screen.getByText("Failed")).toBeTruthy()); - expect(screen.queryByText("Syncing")).toBeNull(); + expect(screen.queryByText("Pending")).toBeNull(); expect(navigation.refresh).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/app/(app)/repos/components/reposTable.tsx b/packages/web/src/app/(app)/repos/components/reposTable.tsx index 3a0cfb358..3a8e667df 100644 --- a/packages/web/src/app/(app)/repos/components/reposTable.tsx +++ b/packages/web/src/app/(app)/repos/components/reposTable.tsx @@ -51,6 +51,7 @@ import { getBrowsePath } from "../../browse/hooks/utils"; import type { RepoIndexingStatusesResponse } from "../types"; import { RepoActionsMenu } from "./repoActionsMenu"; import { SyncIssuePopover } from "./syncIssuePopover"; +import { SyncingBadge } from "./syncingBadge"; const POLL_INTERVAL_MS = 5_000; const COMPLETED_BADGE_VISIBLE_MS = 5_000; @@ -191,10 +192,11 @@ const SyncAnnotationBadge = ({ ); case "SYNCING": return ( - - - Syncing - + ); case "WARNING": return ( @@ -571,6 +573,7 @@ export const ReposTable = ({ id: jobId, data: { repoId }, status: "PENDING", + startedAt: null, errorMessage: null, result: null, }); @@ -585,6 +588,7 @@ export const ReposTable = ({ id: jobId, data: { repoId }, status: "PENDING", + startedAt: null, errorMessage: null, result: null, }); diff --git a/packages/web/src/app/(app)/repos/components/syncingBadge.test.tsx b/packages/web/src/app/(app)/repos/components/syncingBadge.test.tsx new file mode 100644 index 000000000..695c4ca42 --- /dev/null +++ b/packages/web/src/app/(app)/repos/components/syncingBadge.test.tsx @@ -0,0 +1,33 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SyncingBadge } from "./syncingBadge"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("SyncingBadge", () => { + test("shows a live job duration", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-31T12:00:00.000Z")); + render( + , + ); + + expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("1m 30s")).toBeTruthy(); + + act(() => vi.advanceTimersByTime(1_000)); + + expect(screen.getByText("1m 31s")).toBeTruthy(); + }); + + test("shows pending while the indexing job is waiting to start", () => { + render(); + + expect(screen.getByText("Pending")).toBeTruthy(); + expect(screen.queryByText("Syncing")).toBeNull(); + expect(screen.queryByText(/\d+s/)).toBeNull(); + }); +}); diff --git a/packages/web/src/app/(app)/repos/components/syncingBadge.tsx b/packages/web/src/app/(app)/repos/components/syncingBadge.tsx new file mode 100644 index 000000000..3d6ceb26e --- /dev/null +++ b/packages/web/src/app/(app)/repos/components/syncingBadge.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +const DURATION_UPDATE_INTERVAL_MS = 1_000; + +const formatJobDuration = (durationMs: number) => { + const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000)); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor(totalSeconds / 3_600) % 24; + const minutes = Math.floor(totalSeconds / 60) % 60; + const seconds = totalSeconds % 60; + + return [ + days > 0 ? `${days}d` : null, + days > 0 || hours > 0 ? `${hours}h` : null, + days > 0 || hours > 0 || minutes > 0 ? `${minutes}m` : null, + `${seconds}s`, + ].filter(Boolean).join(" "); +}; + +type SyncingBadgeProps = { + startedAt: number | null; +}; + +export const SyncingBadge = ({ startedAt }: SyncingBadgeProps) => { + const [currentTime, setCurrentTime] = useState(() => Date.now()); + + useEffect(() => { + if (startedAt === null) { + return; + } + + setCurrentTime(Date.now()); + const interval = window.setInterval(() => { + setCurrentTime(Date.now()); + }, DURATION_UPDATE_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [startedAt]); + + return ( + + + {startedAt === null + ? Pending + : ( + <> + Syncing + + + {formatJobDuration(currentTime - startedAt)} + + + )} + + ); +}; diff --git a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx index a1fb02e92..5849980fd 100644 --- a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx +++ b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx @@ -239,6 +239,7 @@ describe("ConnectionsTable", () => { id: "active-job", data: { connectionId: connections[0].id }, status: "IN_PROGRESS", + startedAt: null, errorMessage: null, result: null, }, @@ -293,6 +294,7 @@ describe("ConnectionsTable", () => { id: "first-job", data: { connectionId: connection.id }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "SUCCESS" }, }, @@ -333,6 +335,7 @@ describe("ConnectionsTable", () => { id: "job-1", data: { connectionId: connections[0].id }, status, + startedAt: null, errorMessage: null, result: null, }, @@ -351,6 +354,7 @@ describe("ConnectionsTable", () => { id: "failed-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed", result: null, }, @@ -371,6 +375,7 @@ describe("ConnectionsTable", () => { id: "warning-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed", result: null, }, @@ -396,6 +401,7 @@ describe("ConnectionsTable", () => { id: "partial-job", data: { connectionId: connections[0].id }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "PARTIAL_SUCCESS", @@ -438,6 +444,7 @@ describe("ConnectionsTable", () => { id: "failed-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed while discovering repositories", result: null, }, @@ -505,6 +512,7 @@ describe("ConnectionsTable", () => { id: "failed-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "fetch failed", result: null, }, @@ -544,6 +552,7 @@ describe("ConnectionsTable", () => { id: "job-1", data: { connectionId: connections[0].id }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "SUCCESS" }, }, diff --git a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx index f901f3065..52fc9aee9 100644 --- a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx +++ b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx @@ -318,6 +318,7 @@ export const ConnectionsTable = ({ id: jobId, data: { connectionId }, status: "PENDING", + startedAt: null, errorMessage: null, result: null, }); diff --git a/packages/web/src/features/connections/connectionSyncCounts.server.test.ts b/packages/web/src/features/connections/connectionSyncCounts.server.test.ts index c5c40bbc0..5fab08b9a 100644 --- a/packages/web/src/features/connections/connectionSyncCounts.server.test.ts +++ b/packages/web/src/features/connections/connectionSyncCounts.server.test.ts @@ -39,6 +39,7 @@ const job = ( id, data: { connectionId }, status, + startedAt: null, errorMessage: status === "FAILED" ? "sync failed" : null, result, });