Skip to content

Commit 2740efb

Browse files
author
marcus
committed
feat(sync): harden supabase queue on vercel, add chunked backfill UX, and switch commit matching to PAT identity
1 parent 4f54f62 commit 2740efb

9 files changed

Lines changed: 344 additions & 91 deletions

File tree

src/app/api/onboarding/author-emails/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@ export async function POST(request: Request) {
66
const { appUser } = await requireAppUser();
77
const form = await request.formData();
88
const raw = String(form.get("emails") ?? "");
9+
const providerRaw = String(form.get("provider") ?? "").trim();
910

1011
const emails = raw
1112
.split(",")
1213
.map((email) => email.trim().toLowerCase())
1314
.filter(Boolean);
1415

16+
const provider =
17+
providerRaw === "GITLAB" || providerRaw === "AZURE_DEVOPS" || providerRaw === "GITHUB"
18+
? providerRaw
19+
: undefined;
20+
1521
await prisma.integration.updateMany({
16-
where: { userId: appUser.id },
22+
where: provider ? { userId: appUser.id, provider } : { userId: appUser.id },
1723
data: { authorEmails: emails },
1824
});
1925

src/app/dashboard/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export default async function DashboardPage({
5959
}),
6060
prisma.manualHighlight.findMany({ where: { userId: appUser.id }, orderBy: { date: "desc" }, take: 20 }),
6161
prisma.publicShare.findMany({ where: { userId: appUser.id }, orderBy: { createdAt: "desc" }, take: 5 }),
62-
listBackfillJobsForUser(appUser.id, 8),
62+
listBackfillJobsForUser(appUser.id, 120),
6363
]);
6464
const activities = activitiesRaw as ActivityRow[];
6565
const highlights = highlightsRaw as ManualHighlightRow[];

src/components/backfill-jobs-panel.tsx

Lines changed: 208 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,19 @@ import { useAppToast } from "@/components/providers";
44
import { Button } from "@/components/ui/button";
55
import { useBackfillActionMutation } from "@/lib/api/hooks";
66
import { startSyncWatch } from "@/lib/sync-watch";
7-
import { AlertCircle, CheckCircle2, Clock3, Loader2, RefreshCw, Trash2, XCircle } from "lucide-react";
7+
import {
8+
AlertCircle,
9+
CheckCircle2,
10+
ChevronDown,
11+
ChevronRight,
12+
Clock3,
13+
Loader2,
14+
RefreshCw,
15+
Trash2,
16+
XCircle,
17+
} from "lucide-react";
818
import { useRouter } from "next/navigation";
9-
import { useState } from "react";
19+
import { useMemo, useState } from "react";
1020

1121
export type BackfillJobRow = {
1222
id: string;
@@ -22,13 +32,76 @@ type Props = {
2232
jobs: BackfillJobRow[];
2333
};
2434

35+
type BackfillGroup = {
36+
key: string;
37+
year: number;
38+
provider: BackfillJobRow["provider"];
39+
jobs: BackfillJobRow[];
40+
queuedCount: number;
41+
runningCount: number;
42+
completedCount: number;
43+
failedCount: number;
44+
status: BackfillJobRow["status"];
45+
latestError: string | null;
46+
};
47+
2548
export function BackfillJobsPanel({ jobs }: Props) {
2649
const router = useRouter();
2750
const { pushToast } = useAppToast();
2851
const retryMutation = useBackfillActionMutation("retry");
2952
const deleteMutation = useBackfillActionMutation("delete");
3053
const cleanupMutation = useBackfillActionMutation("cleanup");
3154
const [busyAction, setBusyAction] = useState<string | null>(null);
55+
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
56+
57+
const groups = useMemo<BackfillGroup[]>(() => {
58+
const map = new Map<string, BackfillGroup>();
59+
for (const job of jobs) {
60+
const key = `${job.year}:${job.provider ?? "ALL"}`;
61+
const existing = map.get(key);
62+
if (!existing) {
63+
map.set(key, {
64+
key,
65+
year: job.year,
66+
provider: job.provider,
67+
jobs: [job],
68+
queuedCount: job.status === "queued" ? 1 : 0,
69+
runningCount: job.status === "running" ? 1 : 0,
70+
completedCount: job.status === "completed" ? 1 : 0,
71+
failedCount: job.status === "failed" ? 1 : 0,
72+
status: job.status,
73+
latestError: job.status === "failed" ? job.errorMessage : null,
74+
});
75+
continue;
76+
}
77+
78+
existing.jobs.push(job);
79+
if (job.status === "queued") existing.queuedCount += 1;
80+
if (job.status === "running") existing.runningCount += 1;
81+
if (job.status === "completed") existing.completedCount += 1;
82+
if (job.status === "failed") {
83+
existing.failedCount += 1;
84+
if (!existing.latestError && job.errorMessage) existing.latestError = job.errorMessage;
85+
}
86+
}
87+
88+
const list = Array.from(map.values()).map((group) => {
89+
const sortedJobs = [...group.jobs].sort((a, b) => {
90+
const rank = { running: 0, queued: 1, failed: 2, completed: 3 } as const;
91+
return rank[a.status] - rank[b.status];
92+
});
93+
let status: BackfillJobRow["status"] = "completed";
94+
if (group.runningCount > 0) status = "running";
95+
else if (group.queuedCount > 0) status = "queued";
96+
else if (group.failedCount > 0) status = "failed";
97+
return { ...group, jobs: sortedJobs, status };
98+
});
99+
100+
return list.sort((a, b) => {
101+
if (a.year !== b.year) return b.year - a.year;
102+
return formatProvider(a.provider).localeCompare(formatProvider(b.provider));
103+
});
104+
}, [jobs]);
32105

33106
async function postForm(action: "retry" | "delete" | "cleanup", jobId?: string) {
34107
const actionKey = `${action}:${jobId ?? "all"}`;
@@ -53,9 +126,40 @@ export function BackfillJobsPanel({ jobs }: Props) {
53126
router.refresh();
54127
}
55128

129+
async function runGroupAction(action: "retry-group" | "delete-group", group: BackfillGroup) {
130+
const key = `${action}:${group.key}`;
131+
setBusyAction(key);
132+
133+
try {
134+
if (action === "retry-group") {
135+
const failedJobs = group.jobs.filter((job) => job.status === "failed");
136+
await Promise.all(failedJobs.map((job) => retryMutation.mutateAsync(job.id)));
137+
if (failedJobs.length > 0) {
138+
startSyncWatch();
139+
pushToast(
140+
{ title: "Backfill retries queued", subtitle: `${failedJobs.length} failed chunk${failedJobs.length > 1 ? "s" : ""} requeued.` },
141+
"info",
142+
);
143+
}
144+
} else {
145+
const deletableJobs = group.jobs.filter((job) => job.status !== "running");
146+
await Promise.all(deletableJobs.map((job) => deleteMutation.mutateAsync(job.id)));
147+
pushToast(
148+
{ title: "Backfill group cleaned", subtitle: `${deletableJobs.length} chunk${deletableJobs.length > 1 ? "s" : ""} removed.` },
149+
"success",
150+
);
151+
}
152+
} catch {
153+
pushToast({ title: "Action failed", subtitle: "Please try again." }, "error");
154+
} finally {
155+
setBusyAction(null);
156+
router.refresh();
157+
}
158+
}
159+
56160
return (
57161
<div className="mt-4 space-y-2">
58-
{jobs.length > 0 ? (
162+
{groups.length > 0 ? (
59163
<div className="mb-2 flex justify-end">
60164
<Button
61165
size="sm"
@@ -64,61 +168,115 @@ export function BackfillJobsPanel({ jobs }: Props) {
64168
disabled={busyAction === "cleanup:all"}
65169
onClick={() => postForm("cleanup")}
66170
>
67-
{busyAction === "cleanup:all" ? <span className="flex gap-1"><Loader2 className="size-4 animate-spin" /> Cleaning...</span> : <span className="flex gap-1"><Trash2 /> Clean completed</span>}
171+
{busyAction === "cleanup:all" ? (
172+
<span className="flex gap-1">
173+
<Loader2 className="size-4 animate-spin" /> Cleaning...
174+
</span>
175+
) : (
176+
<span className="flex gap-1">
177+
<Trash2 />
178+
Clean completed
179+
</span>
180+
)}
68181
</Button>
69182
</div>
70183
) : null}
71184

72-
{jobs.map((job) => (
73-
<div key={job.id} className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-sm">
74-
<div className="flex items-center gap-2">
75-
{renderBackfillStatusIcon(job.status)}
76-
<span className="font-medium">{job.year}</span>
77-
<span className="text-muted-foreground">{formatProvider(job.provider)}</span>
78-
<span className="text-muted-foreground">{formatBackfillStatus(job.status)}</span>
79-
<span className="text-muted-foreground">• attempt {job.attemptCount}</span>
80-
</div>
81-
<div className="flex items-center gap-2">
82-
{job.status === "failed" && job.errorMessage ? (
83-
<span className="max-w-[480px] truncate text-xs text-red-600" title={job.errorMessage}>
84-
{job.errorMessage}
85-
</span>
86-
) : null}
87-
{job.status === "failed" ? (
88-
<Button
89-
size="sm"
90-
variant="outline"
91-
className="gap-1"
185+
{groups.map((group) => {
186+
const expanded = Boolean(expandedGroups[group.key]);
187+
const canRetryFailed = group.failedCount > 0;
188+
const deletableCount = group.jobs.filter((job) => job.status !== "running").length;
189+
190+
return (
191+
<div key={group.key} className="rounded-md border border-border/60 bg-muted/20">
192+
<div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm">
193+
<button
92194
type="button"
93-
disabled={busyAction === `retry:${job.id}`}
94-
onClick={() => postForm("retry", job.id)}
195+
className="inline-flex items-center gap-2 text-left"
196+
onClick={() => setExpandedGroups((prev) => ({ ...prev, [group.key]: !expanded }))}
95197
>
96-
<RefreshCw className={busyAction === `retry:${job.id}` ? "size-4 animate-spin" : "size-4"} />
97-
Retry
98-
</Button>
198+
{expanded ? <ChevronDown className="size-4 text-muted-foreground" /> : <ChevronRight className="size-4 text-muted-foreground" />}
199+
{renderBackfillStatusIcon(group.status)}
200+
<span className="font-medium">{group.year}</span>
201+
<span className="text-muted-foreground">{formatProvider(group.provider)}</span>
202+
<span className="text-muted-foreground">{group.completedCount}/{group.jobs.length} completed</span>
203+
{group.runningCount > 0 ? <span className="text-blue-600">{group.runningCount} running</span> : null}
204+
{group.queuedCount > 0 ? <span className="text-amber-600">{group.queuedCount} queued</span> : null}
205+
{group.failedCount > 0 ? <span className="text-red-600">{group.failedCount} failed</span> : null}
206+
</button>
207+
<div className="flex items-center gap-2">
208+
{canRetryFailed ? (
209+
<Button
210+
size="sm"
211+
variant="outline"
212+
type="button"
213+
disabled={busyAction === `retry-group:${group.key}`}
214+
onClick={() => runGroupAction("retry-group", group)}
215+
className="gap-1"
216+
>
217+
<RefreshCw className={busyAction === `retry-group:${group.key}` ? "size-4 animate-spin" : "size-4"} />
218+
Retry failed
219+
</Button>
220+
) : null}
221+
<Button
222+
size="icon"
223+
variant="destructive"
224+
type="button"
225+
disabled={deletableCount === 0 || busyAction === `delete-group:${group.key}`}
226+
onClick={() => runGroupAction("delete-group", group)}
227+
>
228+
{busyAction === `delete-group:${group.key}` ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
229+
</Button>
230+
</div>
231+
</div>
232+
{expanded ? (
233+
<div className="space-y-2 border-t border-border/60 px-3 py-2">
234+
{group.jobs.map((job) => (
235+
<div key={job.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border/50 bg-background/50 px-2 py-1.5 text-xs">
236+
<div className="flex items-center gap-2">
237+
{renderBackfillStatusIcon(job.status)}
238+
<span className="text-muted-foreground">{formatBackfillStatus(job.status)}</span>
239+
<span className="text-muted-foreground">• attempt {job.attemptCount}</span>
240+
{job.status === "failed" && job.errorMessage ? (
241+
<span className="max-w-[480px] truncate text-red-600" title={job.errorMessage}>
242+
{job.errorMessage}
243+
</span>
244+
) : null}
245+
</div>
246+
<div className="flex items-center gap-2">
247+
{job.status === "failed" ? (
248+
<Button
249+
size="sm"
250+
variant="outline"
251+
type="button"
252+
className="h-7 gap-1 px-2 text-xs"
253+
disabled={busyAction === `retry:${job.id}`}
254+
onClick={() => postForm("retry", job.id)}
255+
>
256+
<RefreshCw className={busyAction === `retry:${job.id}` ? "size-3 animate-spin" : "size-3"} />
257+
Retry
258+
</Button>
259+
) : null}
260+
<Button
261+
size="icon"
262+
variant="destructive"
263+
className="h-7 w-7"
264+
type="button"
265+
disabled={job.status === "running" || busyAction === `delete:${job.id}`}
266+
onClick={() => postForm("delete", job.id)}
267+
>
268+
{busyAction === `delete:${job.id}` ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
269+
</Button>
270+
</div>
271+
</div>
272+
))}
273+
{group.latestError ? <p className="text-xs text-red-600">{group.latestError}</p> : null}
274+
</div>
99275
) : null}
100-
<Button
101-
size="icon"
102-
variant="destructive"
103-
className="gap-1"
104-
type="button"
105-
disabled={busyAction === `delete:${job.id}`}
106-
onClick={() => postForm("delete", job.id)}
107-
>
108-
{busyAction === `delete:${job.id}` ? (
109-
<>
110-
<Loader2 className="size-4 animate-spin" />
111-
</>
112-
) : (
113-
<>
114-
<Trash2 className="size-4" />
115-
</>
116-
)}
117-
</Button>
118276
</div>
119-
</div>
120-
))}
121-
{jobs.length === 0 ? <p className="text-sm text-muted-foreground">No historical backfill jobs yet.</p> : null}
277+
);
278+
})}
279+
{groups.length === 0 ? <p className="text-sm text-muted-foreground">No historical backfill jobs yet.</p> : null}
122280
</div>
123281
);
124282
}

src/components/dashboard-live-updater.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export function DashboardLiveUpdater() {
2525
const latestEventKey = useRef<string | null>(null);
2626
const hasAnnouncedStart = useRef(false);
2727
const latestTerminalRef = useRef<string | null>(null);
28+
const latestRefreshedTerminalRef = useRef<string | null>(null);
29+
const NO_ACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
2830

2931
useEffect(() => {
3032
const source = new EventSource("/api/sync/events");
@@ -82,6 +84,11 @@ export function DashboardLiveUpdater() {
8284
const status = (await response.json()) as SyncStatusPayload;
8385
const watchStartedAt = getSyncWatchStartedAt();
8486
const activeJobCount = Number(status.activeJobCount ?? 0);
87+
const terminalTime = status.latestJobFinishedAt ? Date.parse(status.latestJobFinishedAt) : NaN;
88+
const terminalKey = status.latestJobFinishedAt ? `${status.latestJobStatus ?? "UNKNOWN"}:${status.latestJobFinishedAt}` : null;
89+
const isFromCurrentWatch =
90+
Number.isFinite(terminalTime) && watchStartedAt !== null ? terminalTime >= watchStartedAt : false;
91+
const hasTerminalForCurrentWatch = Boolean(terminalKey && isFromCurrentWatch);
8592

8693
if (activeJobCount > 0 && !hasAnnouncedStart.current) {
8794
hasAnnouncedStart.current = true;
@@ -95,12 +102,14 @@ export function DashboardLiveUpdater() {
95102
return;
96103
}
97104

98-
if (activeJobCount === 0 && hasAnnouncedStart.current) {
99-
const terminalTime = status.latestJobFinishedAt ? Date.parse(status.latestJobFinishedAt) : NaN;
100-
const terminalKey = status.latestJobFinishedAt ? `${status.latestJobStatus ?? "UNKNOWN"}:${status.latestJobFinishedAt}` : null;
101-
const isFromCurrentWatch =
102-
Number.isFinite(terminalTime) && watchStartedAt !== null ? terminalTime >= watchStartedAt : false;
105+
// Incremental refresh: when a new chunk/job reaches terminal state for the current
106+
// watch window, refresh dashboard so newly aggregated days appear immediately.
107+
if (hasTerminalForCurrentWatch && terminalKey && latestRefreshedTerminalRef.current !== terminalKey) {
108+
latestRefreshedTerminalRef.current = terminalKey;
109+
router.refresh();
110+
}
103111

112+
if (activeJobCount === 0 && hasTerminalForCurrentWatch) {
104113
if (terminalKey && latestTerminalRef.current !== terminalKey && isFromCurrentWatch) {
105114
latestTerminalRef.current = terminalKey;
106115
if (status.latestJobStatus === "FAILED") {
@@ -124,7 +133,17 @@ export function DashboardLiveUpdater() {
124133

125134
clearSyncWatch();
126135
hasAnnouncedStart.current = false;
136+
latestRefreshedTerminalRef.current = null;
127137
router.refresh();
138+
return;
139+
}
140+
141+
// Safety valve: if no active jobs are observed for a long time and no terminal
142+
// event can be associated to this watch window, stop polling loop.
143+
if (activeJobCount === 0 && watchStartedAt && Date.now() - watchStartedAt > NO_ACTIVITY_TIMEOUT_MS) {
144+
clearSyncWatch();
145+
hasAnnouncedStart.current = false;
146+
latestRefreshedTerminalRef.current = null;
128147
}
129148
} catch {
130149
// ignore polling errors

0 commit comments

Comments
 (0)