Skip to content

Commit e0d3634

Browse files
authored
connections qol improvements (#195)
* add client side polling to connections list * properly fetch repo image url * add client polling to connection management page, and add ability to sync failed connections
1 parent 3be3680 commit e0d3634

File tree

10 files changed

+257
-78
lines changed

10 files changed

+257
-78
lines changed

packages/backend/src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const DEFAULT_SETTINGS: Settings = {
88
autoDeleteStaleRepos: true,
99
reindexIntervalMs: 1000 * 60,
1010
resyncConnectionPollingIntervalMs: 1000,
11-
reindexRepoPollingInternvalMs: 1000,
11+
reindexRepoPollingIntervalMs: 1000,
1212
indexConcurrencyMultiple: 3,
1313
configSyncConcurrencyMultiple: 3,
1414
}

packages/backend/src/repoCompileUtils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const compileGithubConfig = async (
3030
external_codeHostUrl: hostUrl,
3131
cloneUrl: cloneUrl.toString(),
3232
name: repoName,
33+
imageUrl: repo.owner.avatar_url,
3334
isFork: repo.fork,
3435
isArchived: !!repo.archived,
3536
org: {
@@ -80,6 +81,7 @@ export const compileGitlabConfig = async (
8081
external_codeHostUrl: hostUrl,
8182
cloneUrl: cloneUrl.toString(),
8283
name: project.path_with_namespace,
84+
imageUrl: project.avatar_url,
8385
isFork: isFork,
8486
isArchived: !!project.archived,
8587
org: {
@@ -118,7 +120,6 @@ export const compileGiteaConfig = async (
118120
const hostUrl = config.url ?? 'https://gitea.com';
119121

120122
return giteaRepos.map((repo) => {
121-
const repoUrl = `${hostUrl}/${repo.full_name}`;
122123
const cloneUrl = new URL(repo.clone_url!);
123124

124125
const record: RepoData = {
@@ -127,6 +128,7 @@ export const compileGiteaConfig = async (
127128
external_codeHostUrl: hostUrl,
128129
cloneUrl: cloneUrl.toString(),
129130
name: repo.full_name!,
131+
imageUrl: repo.owner?.avatar_url,
130132
isFork: repo.fork!,
131133
isArchived: !!repo.archived,
132134
org: {

packages/backend/src/repoManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class RepoManager implements IRepoManager {
4949
this.fetchAndScheduleRepoIndexing();
5050
this.garbageCollectRepo();
5151

52-
await new Promise(resolve => setTimeout(resolve, this.settings.reindexRepoPollingInternvalMs));
52+
await new Promise(resolve => setTimeout(resolve, this.settings.reindexRepoPollingIntervalMs));
5353
}
5454
}
5555

packages/backend/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export type Settings = {
7777
/**
7878
* The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed.
7979
*/
80-
reindexRepoPollingInternvalMs: number;
80+
reindexRepoPollingIntervalMs: number;
8181
/**
8282
* The multiple of the number of CPUs to use for indexing.
8383
*/

packages/web/src/actions.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
1313
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
1414
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
1515
import { encrypt } from "@sourcebot/crypto"
16-
import { getConnection } from "./data/connection";
17-
import { ConnectionSyncStatus, Prisma, Invite, OrgRole } from "@sourcebot/db";
16+
import { getConnection, getLinkedRepos } from "./data/connection";
17+
import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org } from "@sourcebot/db";
1818
import { headers } from "next/headers"
1919
import { getStripe } from "@/lib/stripe"
2020
import { getUser } from "@/data/user";
@@ -236,6 +236,41 @@ export const createConnection = async (name: string, type: string, connectionCon
236236
}
237237
}));
238238

239+
export const getConnectionInfoAction = async (connectionId: number, domain: string): Promise<{ connection: Connection, linkedRepos: Repo[] } | ServiceError> =>
240+
withAuth((session) =>
241+
withOrgMembership(session, domain, async (orgId) => {
242+
const connection = await getConnection(connectionId, orgId);
243+
if (!connection) {
244+
return notFound();
245+
}
246+
247+
const linkedRepos = await getLinkedRepos(connectionId, orgId);
248+
249+
return {
250+
connection,
251+
linkedRepos: linkedRepos.map((repo) => repo.repo),
252+
}
253+
})
254+
);
255+
256+
export const getOrgFromDomainAction = async (domain: string): Promise<Org | ServiceError> =>
257+
withAuth((session) =>
258+
withOrgMembership(session, domain, async (orgId) => {
259+
const org = await prisma.org.findUnique({
260+
where: {
261+
id: orgId,
262+
},
263+
});
264+
265+
if (!org) {
266+
return notFound();
267+
}
268+
269+
return org;
270+
})
271+
);
272+
273+
239274
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
240275
withAuth((session) =>
241276
withOrgMembership(session, domain, async (orgId) => {
@@ -298,6 +333,36 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number
298333
}
299334
}));
300335

336+
export const flagConnectionForSync = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
337+
withAuth((session) =>
338+
withOrgMembership(session, domain, async (orgId) => {
339+
const connection = await getConnection(connectionId, orgId);
340+
if (!connection || connection.orgId !== orgId) {
341+
return notFound();
342+
}
343+
344+
if (connection.syncStatus !== "FAILED") {
345+
return {
346+
statusCode: StatusCodes.BAD_REQUEST,
347+
errorCode: ErrorCode.CONNECTION_NOT_FAILED,
348+
message: "Connection is not in a failed state. Cannot flag for sync.",
349+
} satisfies ServiceError;
350+
}
351+
352+
await prisma.connection.update({
353+
where: {
354+
id: connection.id,
355+
},
356+
data: {
357+
syncStatus: "SYNC_NEEDED",
358+
}
359+
});
360+
361+
return {
362+
success: true,
363+
}
364+
}));
365+
301366
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
302367
withAuth((session) =>
303368
withOrgMembership(session, domain, async (orgId) => {

packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const RepoListItem = ({
2323
case RepoIndexingStatus.NEW:
2424
return 'Waiting...';
2525
case RepoIndexingStatus.IN_INDEX_QUEUE:
26+
return 'In index queue...';
2627
case RepoIndexingStatus.INDEXING:
2728
return 'Indexing...';
2829
case RepoIndexingStatus.INDEXED:

packages/web/src/app/[domain]/connections/[id]/page.tsx

Lines changed: 116 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { NotFound } from "@/app/[domain]/components/notFound";
24
import {
35
Breadcrumb,
@@ -10,58 +12,108 @@ import {
1012
import { ScrollArea } from "@/components/ui/scroll-area";
1113
import { TabSwitcher } from "@/components/ui/tab-switcher";
1214
import { Tabs, TabsContent } from "@/components/ui/tabs";
13-
import { getConnection, getLinkedRepos } from "@/data/connection";
1415
import { ConnectionIcon } from "../components/connectionIcon";
1516
import { Header } from "../../components/header";
1617
import { ConfigSetting } from "./components/configSetting";
1718
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting";
1819
import { DisplayNameSetting } from "./components/displayNameSetting";
1920
import { RepoListItem } from "./components/repoListItem";
20-
import { getOrgFromDomain } from "@/data/org";
21-
import { PageNotFound } from "../../components/pageNotFound";
22-
23-
interface ConnectionManagementPageProps {
24-
params: {
25-
id: string;
26-
domain: string;
27-
},
28-
searchParams: {
29-
tab?: string;
30-
}
31-
}
21+
import { useParams, useSearchParams } from "next/navigation";
22+
import { useEffect, useState } from "react";
23+
import { Connection, Repo, Org } from "@sourcebot/db";
24+
import { getConnectionInfoAction, getOrgFromDomainAction, flagConnectionForSync } from "@/actions";
25+
import { isServiceError } from "@/lib/utils";
26+
import { Button } from "@/components/ui/button";
27+
import { ReloadIcon } from "@radix-ui/react-icons";
28+
import { useToast } from "@/components/hooks/use-toast";
3229

33-
export default async function ConnectionManagementPage({
34-
params,
35-
searchParams,
36-
}: ConnectionManagementPageProps) {
37-
const org = await getOrgFromDomain(params.domain);
38-
if (!org) {
39-
return <PageNotFound />
40-
}
30+
export default function ConnectionManagementPage() {
31+
const params = useParams();
32+
const searchParams = useSearchParams();
33+
const { toast } = useToast();
34+
const [org, setOrg] = useState<Org | null>(null);
35+
const [connection, setConnection] = useState<Connection | null>(null);
36+
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([]);
37+
const [loading, setLoading] = useState(true);
38+
const [error, setError] = useState<string | null>(null);
4139

42-
const connectionId = Number(params.id);
43-
if (isNaN(connectionId)) {
44-
return (
45-
<NotFound
46-
className="flex w-full h-full items-center justify-center"
47-
message="Connection not found"
48-
/>
49-
)
40+
useEffect(() => {
41+
const loadData = async () => {
42+
try {
43+
const orgResult = await getOrgFromDomainAction(params.domain as string);
44+
if (isServiceError(orgResult)) {
45+
setError(orgResult.message);
46+
setLoading(false);
47+
return;
48+
}
49+
setOrg(orgResult);
50+
51+
const connectionId = Number(params.id);
52+
if (isNaN(connectionId)) {
53+
setError("Invalid connection ID");
54+
setLoading(false);
55+
return;
56+
}
57+
58+
const connectionInfoResult = await getConnectionInfoAction(connectionId, params.domain as string);
59+
if (isServiceError(connectionInfoResult)) {
60+
setError(connectionInfoResult.message);
61+
setLoading(false);
62+
return;
63+
}
64+
65+
connectionInfoResult.linkedRepos.sort((a, b) => {
66+
// Helper function to get priority of indexing status
67+
const getPriority = (status: string) => {
68+
switch (status) {
69+
case 'FAILED': return 0;
70+
case 'IN_INDEX_QUEUE':
71+
case 'INDEXING': return 1;
72+
case 'INDEXED': return 2;
73+
default: return 3;
74+
}
75+
};
76+
77+
const priorityA = getPriority(a.repoIndexingStatus);
78+
const priorityB = getPriority(b.repoIndexingStatus);
79+
80+
// First sort by priority
81+
if (priorityA !== priorityB) {
82+
return priorityA - priorityB;
83+
}
84+
85+
// If same priority, sort by createdAt
86+
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
87+
});
88+
89+
setConnection(connectionInfoResult.connection);
90+
setLinkedRepos(connectionInfoResult.linkedRepos);
91+
setLoading(false);
92+
} catch (err) {
93+
setError(err instanceof Error ? err.message : "An error occurred while loading the connection. If the problem persists, please contact us at team@sourcebot.dev");
94+
setLoading(false);
95+
}
96+
};
97+
98+
loadData();
99+
const intervalId = setInterval(loadData, 1000);
100+
return () => clearInterval(intervalId);
101+
}, [params.domain, params.id]);
102+
103+
if (loading) {
104+
return <div>Loading...</div>;
50105
}
51106

52-
const connection = await getConnection(Number(params.id), org.id);
53-
if (!connection) {
107+
if (error || !org || !connection) {
54108
return (
55109
<NotFound
56110
className="flex w-full h-full items-center justify-center"
57-
message="Connection not found"
111+
message={error || "Not found"}
58112
/>
59-
)
113+
);
60114
}
61115

62-
const linkedRepos = await getLinkedRepos(connectionId, org.id);
63-
64-
const currentTab = searchParams.tab || "overview";
116+
const currentTab = searchParams.get("tab") || "overview";
65117

66118
return (
67119
<Tabs
@@ -116,7 +168,30 @@ export default async function ConnectionManagementPage({
116168
</div>
117169
<div className="rounded-lg border border-border p-4 bg-background">
118170
<h2 className="text-sm font-medium text-muted-foreground">Status</h2>
119-
<p className="mt-2 text-sm">{connection.syncStatus}</p>
171+
<div className="flex items-center gap-2">
172+
<p className="mt-2 text-sm">{connection.syncStatus}</p>
173+
{connection.syncStatus === "FAILED" && (
174+
<Button
175+
variant="outline"
176+
size="sm"
177+
className="mt-2 rounded-full"
178+
onClick={async () => {
179+
const result = await flagConnectionForSync(connection.id, params.domain as string);
180+
if (isServiceError(result)) {
181+
toast({
182+
description: `❌ Failed to flag connection for sync. Reason: ${result.message}`,
183+
})
184+
} else {
185+
toast({
186+
description: "✅ Connection flagged for sync.",
187+
})
188+
}
189+
}}
190+
>
191+
<ReloadIcon className="h-4 w-4" />
192+
</Button>
193+
)}
194+
</div>
120195
</div>
121196
</div>
122197
</div>
@@ -127,12 +202,12 @@ export default async function ConnectionManagementPage({
127202
<div className="flex flex-col gap-4">
128203
{linkedRepos
129204
.sort((a, b) => {
130-
const aIndexedAt = a.repo.indexedAt ?? new Date();
131-
const bIndexedAt = b.repo.indexedAt ?? new Date();
205+
const aIndexedAt = a.indexedAt ?? new Date();
206+
const bIndexedAt = b.indexedAt ?? new Date();
132207

133208
return bIndexedAt.getTime() - aIndexedAt.getTime();
134209
})
135-
.map(({ repo }) => (
210+
.map((repo) => (
136211
<RepoListItem
137212
key={repo.id}
138213
imageUrl={repo.imageUrl ?? undefined}
@@ -162,6 +237,5 @@ export default async function ConnectionManagementPage({
162237
/>
163238
</TabsContent>
164239
</Tabs>
165-
166-
)
240+
);
167241
}

0 commit comments

Comments
 (0)