feat(dashboard): add Refresh commits button, separate from Recompute
The single Recompute button conflated two ideas: re-aggregating the cached metrics (instant, costs nothing) and pulling new commits from Forgejo (slow, hits the external API). A user who clicked Recompute expecting Recent Work to update was waiting on a metric that only depends on what is already in the database. Split the two actions: * Refresh commits — wraps ingestProject(stopAfterKnownPages: 1), the same path as the hourly cron, scoped to one project. Snappy because it stops once a full page is already known. * Recompute — unchanged behaviour. Re-aggregates the cached commits into the project_metrics row. Both actions revalidate the dashboard, so Recent Work, head-line KPIs and the 'Updated just now' indicator all stay consistent within a single click.
This commit is contained in:
parent
d909a8bb34
commit
4ce9b76874
3 changed files with 124 additions and 5 deletions
|
|
@ -77,4 +77,44 @@ export async function backfillProject(input: {
|
|||
|
||||
revalidatePath(`/admin/projects/${input.projectId}`);
|
||||
return { created: result.created, skipped: result.skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull whatever is new from Forgejo for one project.
|
||||
*
|
||||
* Same code path as the hourly `/api/cron/reconcile`, scoped to a single
|
||||
* project and gated behind the same `requireOwner` the backfill uses. Stops at
|
||||
* the first fully-known page — the dashboard button is for "did anything push
|
||||
* since the last reconcile?" rather than for restoring a lost history, and
|
||||
* reading only what has changed keeps it interactive.
|
||||
*
|
||||
* The cached metrics are refreshed inside `ingestProject`, so a successful
|
||||
* call here both populates Recent Work and updates the headline numbers.
|
||||
*/
|
||||
export async function refreshCommitsAction(input: {
|
||||
projectId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
}): Promise<{ created: number; skipped: number; pagesRead: number }> {
|
||||
await requireOwner();
|
||||
|
||||
const pb = await getAdminPb();
|
||||
const project = await pb
|
||||
.collection("projects")
|
||||
.getOne<ProjectRecord>(input.projectId);
|
||||
|
||||
if (!project.repo_owner || !project.repo_name) {
|
||||
throw new Error("Link a repository to this project first");
|
||||
}
|
||||
|
||||
if (!(await repoExists(project.repo_owner, project.repo_name))) {
|
||||
throw new Error(
|
||||
`Forgejo has no repository ${project.repo_owner}/${project.repo_name}, or it is not readable`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ingestProject(project, { stopAfterKnownPages: 1 });
|
||||
|
||||
revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`);
|
||||
return result;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { MonthBars } from "@/components/dashboard/month-bars";
|
|||
import { Methodology } from "@/components/dashboard/methodology";
|
||||
import { RecentActivity } from "@/components/dashboard/recent-activity";
|
||||
import { RecomputeButton } from "@/components/dashboard/recompute-button";
|
||||
import { RefreshCommitsButton } from "@/components/dashboard/refresh-commits-button";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getServerPb } from "@/lib/pb/server";
|
||||
import { loadBoard } from "@/lib/data/board";
|
||||
|
|
@ -53,11 +54,18 @@ export default async function DashboardPage({
|
|||
<AppShell role={session.role} currentPath={boardHref}>
|
||||
<TopBar title={board.project.name} subtitle={board.client.name}>
|
||||
{session.role === "owner" && (
|
||||
<RecomputeButton
|
||||
projectId={board.project.id}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
<span className="flex items-center gap-2">
|
||||
<RefreshCommitsButton
|
||||
projectId={board.project.id}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
<RecomputeButton
|
||||
projectId={board.project.id}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</TopBar>
|
||||
|
||||
|
|
|
|||
71
src/components/dashboard/refresh-commits-button.tsx
Normal file
71
src/components/dashboard/refresh-commits-button.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { CloudDownload } from "lucide-react";
|
||||
|
||||
import { refreshCommitsAction } from "@/app/actions/ingest";
|
||||
|
||||
/**
|
||||
* "Fetch whatever is new from Forgejo" — sibling to {@link RecomputeButton}.
|
||||
*
|
||||
* Recompute reads cached commits and re-aggregates them into the metrics
|
||||
* table; this one actually talks to Forgejo. It is the same code path as the
|
||||
* hourly cron, scoped to one project and stopped at the first known page so it
|
||||
* stays interactive on the dashboard.
|
||||
*/
|
||||
export function RefreshCommitsButton({
|
||||
projectId,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
}: {
|
||||
projectId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await refreshCommitsAction({
|
||||
projectId,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
});
|
||||
if (result.created === 0) {
|
||||
setStatus("Already up to date");
|
||||
} else {
|
||||
setStatus(
|
||||
`${result.created} new commit${result.created === 1 ? "" : "s"}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Refresh failed");
|
||||
}
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
|
||||
>
|
||||
<CloudDownload size={13} strokeWidth={2} />
|
||||
{pending ? "Refreshing…" : "Refresh commits"}
|
||||
</button>
|
||||
{status && !error && (
|
||||
<span className="text-xs text-text-muted">{status}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span role="alert" className="text-xs text-accent">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue