feat(forgejo): enhance repository selection and sync functionality in metrics cards
This commit is contained in:
parent
9a21ce41d4
commit
7a29aaac14
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { type KeyboardEvent, type MouseEvent, useMemo } from "react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
|
|
@ -75,6 +81,7 @@ import {
|
|||
getForgejoLastPush,
|
||||
getForgejoMetrics,
|
||||
getForgejoRepositories,
|
||||
syncRepository,
|
||||
type ForgejoHeatmapDay,
|
||||
type ForgejoIssueMetrics,
|
||||
type ForgejoRepository,
|
||||
|
|
@ -119,6 +126,7 @@ type GatewaySnapshot = GatewayTarget & {
|
|||
|
||||
const DASH = "—";
|
||||
const FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const ALL_FORGEJO_REPOSITORIES = "all";
|
||||
const DASHBOARD_RANGE = "7d";
|
||||
const DASHBOARD_RANGE_DAYS = 7;
|
||||
const DASHBOARD_RANGE_LABEL = "7 days";
|
||||
|
|
@ -429,6 +437,9 @@ const toSessionSummaries = (
|
|||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { isSignedIn } = useAuth();
|
||||
const [selectedForgejoRepositoryId, setSelectedForgejoRepositoryId] =
|
||||
useState(ALL_FORGEJO_REPOSITORIES);
|
||||
const [isRefreshingForgejoSync, setIsRefreshingForgejoSync] = useState(false);
|
||||
|
||||
const boardsQuery = useListBoardsApiV1BoardsGet<
|
||||
listBoardsApiV1BoardsGetResponse,
|
||||
|
|
@ -502,6 +513,24 @@ export default function DashboardPage() {
|
|||
() => forgejoRepositoriesQuery.data ?? [],
|
||||
[forgejoRepositoriesQuery.data],
|
||||
);
|
||||
const selectedForgejoRepository = useMemo(
|
||||
() =>
|
||||
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||
? null
|
||||
: forgejoRepositories.find(
|
||||
(repository) => repository.id === selectedForgejoRepositoryId,
|
||||
) ?? null,
|
||||
[forgejoRepositories, selectedForgejoRepositoryId],
|
||||
);
|
||||
const scopedForgejoRepositories = useMemo(
|
||||
() =>
|
||||
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||
? forgejoRepositories
|
||||
: selectedForgejoRepository
|
||||
? [selectedForgejoRepository]
|
||||
: [],
|
||||
[forgejoRepositories, selectedForgejoRepository, selectedForgejoRepositoryId],
|
||||
);
|
||||
const forgejoOrganizationId = useMemo(
|
||||
() =>
|
||||
forgejoRepositories.find((repository) => repository.organization_id)
|
||||
|
|
@ -509,16 +538,42 @@ export default function DashboardPage() {
|
|||
[forgejoRepositories],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES &&
|
||||
forgejoRepositories.length > 0 &&
|
||||
!forgejoRepositories.some(
|
||||
(repository) => repository.id === selectedForgejoRepositoryId,
|
||||
)
|
||||
) {
|
||||
setSelectedForgejoRepositoryId(ALL_FORGEJO_REPOSITORIES);
|
||||
}
|
||||
}, [forgejoRepositories, selectedForgejoRepositoryId]);
|
||||
|
||||
const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({
|
||||
queryKey: ["dashboard", "forgejo", "metrics", forgejoOrganizationId],
|
||||
queryKey: [
|
||||
"dashboard",
|
||||
"forgejo",
|
||||
"metrics",
|
||||
forgejoOrganizationId,
|
||||
selectedForgejoRepositoryId,
|
||||
],
|
||||
enabled: Boolean(
|
||||
isSignedIn &&
|
||||
!forgejoRepositoriesQuery.isLoading &&
|
||||
!forgejoRepositoriesQuery.error,
|
||||
!forgejoRepositoriesQuery.error &&
|
||||
(
|
||||
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||
? forgejoOrganizationId
|
||||
: selectedForgejoRepository
|
||||
),
|
||||
),
|
||||
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
|
||||
refetchOnMount: "always",
|
||||
queryFn: () => {
|
||||
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
|
||||
return getForgejoMetrics({ repository_id: selectedForgejoRepositoryId });
|
||||
}
|
||||
if (!forgejoOrganizationId) return Promise.resolve(null);
|
||||
return getForgejoMetrics({ organization_id: forgejoOrganizationId });
|
||||
},
|
||||
|
|
@ -551,6 +606,34 @@ export default function DashboardPage() {
|
|||
},
|
||||
});
|
||||
|
||||
const handleRefreshForgejoLastSync = async () => {
|
||||
if (isRefreshingForgejoSync) return;
|
||||
|
||||
const repositoriesToSync =
|
||||
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||
? forgejoRepositories.filter((repository) => repository.active)
|
||||
: selectedForgejoRepository
|
||||
? [selectedForgejoRepository]
|
||||
: [];
|
||||
|
||||
setIsRefreshingForgejoSync(true);
|
||||
try {
|
||||
for (const repository of repositoriesToSync) {
|
||||
await syncRepository(repository.id);
|
||||
}
|
||||
await Promise.all([
|
||||
forgejoRepositoriesQuery.refetch(),
|
||||
forgejoMetricsQuery.refetch(),
|
||||
forgejoHeatmapQuery.refetch(),
|
||||
forgejoLastPushQuery.refetch(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh Forgejo sync status:", error);
|
||||
} finally {
|
||||
setIsRefreshingForgejoSync(false);
|
||||
}
|
||||
};
|
||||
|
||||
const boards = useMemo(
|
||||
() =>
|
||||
boardsQuery.data?.status === 200
|
||||
|
|
@ -1238,6 +1321,11 @@ export default function DashboardPage() {
|
|||
<ForgejoIssueMetricCards
|
||||
metrics={forgejoIssueMetrics}
|
||||
repositories={forgejoRepositories}
|
||||
metricRepositories={scopedForgejoRepositories}
|
||||
selectedRepositoryId={selectedForgejoRepositoryId}
|
||||
onSelectedRepositoryChange={setSelectedForgejoRepositoryId}
|
||||
onRefreshLastSync={handleRefreshForgejoLastSync}
|
||||
isRefreshingLastSync={isRefreshingForgejoSync}
|
||||
isLoading={forgejoIssueMetricsLoading}
|
||||
error={forgejoIssueMetricsError}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,22 @@ import {
|
|||
|
||||
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type ForgejoIssueMetricCardsProps = {
|
||||
metrics: ForgejoIssueMetrics | null;
|
||||
repositories: ForgejoRepository[];
|
||||
metricRepositories?: ForgejoRepository[];
|
||||
selectedRepositoryId: string;
|
||||
onSelectedRepositoryChange: (repositoryId: string) => void;
|
||||
onRefreshLastSync: () => void;
|
||||
isRefreshingLastSync?: boolean;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
|
@ -33,10 +45,16 @@ type MetricCard = {
|
|||
href: string;
|
||||
tone: MetricTone;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
action?: {
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||
const STALE_SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
const ALL_REPOSITORIES_VALUE = "all";
|
||||
|
||||
const formatCount = (value: number | null | undefined): string =>
|
||||
numberFormatter.format(Math.max(0, Math.round(Number(value ?? 0))));
|
||||
|
|
@ -67,6 +85,9 @@ const newestDate = (dates: Date[]): Date | null => {
|
|||
);
|
||||
};
|
||||
|
||||
const repositoryLabel = (repository: ForgejoRepository): string =>
|
||||
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||
|
||||
// ── Tone → icon badge (background + text color) ────────────────────────────
|
||||
const toneIconClasses: Record<MetricTone, string> = {
|
||||
amber: "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
|
||||
|
|
@ -102,6 +123,8 @@ function buildSyncHealthCard(
|
|||
metrics: ForgejoIssueMetrics | null,
|
||||
repositories: ForgejoRepository[],
|
||||
nowMs: number | null,
|
||||
onRefreshLastSync: () => void,
|
||||
isRefreshingLastSync: boolean,
|
||||
): MetricCard {
|
||||
const repositoryCount = repositories.length;
|
||||
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
||||
|
|
@ -122,7 +145,7 @@ function buildSyncHealthCard(
|
|||
|
||||
if (repositoryCount === 0) {
|
||||
return {
|
||||
title: "Last Sync Health",
|
||||
title: "Last Sync",
|
||||
value: "No repos",
|
||||
caption: "Add repositories to track sync status.",
|
||||
href: "/git-projects/repositories",
|
||||
|
|
@ -132,41 +155,61 @@ function buildSyncHealthCard(
|
|||
}
|
||||
if (syncErrorCount > 0) {
|
||||
return {
|
||||
title: "Last Sync Health",
|
||||
title: "Last Sync",
|
||||
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
|
||||
caption: "Repository sync needs attention.",
|
||||
href: "/git-projects/repositories",
|
||||
tone: "danger",
|
||||
icon: ShieldAlert,
|
||||
action: {
|
||||
label: "Refresh Last Sync",
|
||||
isLoading: isRefreshingLastSync,
|
||||
onClick: onRefreshLastSync,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!latestSync) {
|
||||
return {
|
||||
title: "Last Sync Health",
|
||||
title: "Last Sync",
|
||||
value: "Waiting",
|
||||
caption: "Repositories have not synced yet.",
|
||||
href: "/git-projects/repositories",
|
||||
tone: "slate",
|
||||
icon: Clock3,
|
||||
action: {
|
||||
label: "Refresh Last Sync",
|
||||
isLoading: isRefreshingLastSync,
|
||||
onClick: onRefreshLastSync,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
|
||||
return {
|
||||
title: "Last Sync Health",
|
||||
title: "Last Sync",
|
||||
value: "Stale",
|
||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||
href: "/git-projects/repositories",
|
||||
tone: "amber",
|
||||
icon: Clock3,
|
||||
action: {
|
||||
label: "Refresh Last Sync",
|
||||
isLoading: isRefreshingLastSync,
|
||||
onClick: onRefreshLastSync,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Last Sync Health",
|
||||
title: "Last Sync",
|
||||
value: "Healthy",
|
||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||
href: "/git-projects/repositories",
|
||||
tone: "cyan",
|
||||
icon: ShieldCheck,
|
||||
action: {
|
||||
label: "Refresh Last Sync",
|
||||
isLoading: isRefreshingLastSync,
|
||||
onClick: onRefreshLastSync,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -187,14 +230,18 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
|||
const valueParts = card.value.split(/(\d[\d,]*)/g);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={card.href}
|
||||
<div
|
||||
className={cn(
|
||||
"group flex min-w-0 flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
|
||||
"group relative flex min-w-0 flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm",
|
||||
toneCardClasses[card.tone],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link
|
||||
href={card.href}
|
||||
className="absolute inset-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
aria-label={`Open ${card.title}`}
|
||||
/>
|
||||
<div className="relative z-10 flex items-start justify-between gap-3 pointer-events-none">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
{card.title}
|
||||
|
|
@ -216,20 +263,43 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
|
||||
toneIconClasses[card.tone],
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<div className="flex shrink-0 items-center gap-1.5 pointer-events-auto">
|
||||
{card.action ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
card.action?.onClick();
|
||||
}}
|
||||
disabled={card.action.isLoading}
|
||||
className={cn(
|
||||
"rounded-lg border p-2 transition hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] disabled:cursor-not-allowed disabled:opacity-70",
|
||||
toneIconClasses[card.tone],
|
||||
)}
|
||||
title={card.action.label}
|
||||
aria-label={card.action.label}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4", card.action.isLoading && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
|
||||
toneIconClasses[card.tone],
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-3 pointer-events-none">
|
||||
<p className="min-w-0 text-sm text-muted">{card.caption}</p>
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted transition group-hover:text-[color:var(--accent)]" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -237,6 +307,11 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
|||
export function ForgejoIssueMetricCards({
|
||||
metrics,
|
||||
repositories,
|
||||
metricRepositories,
|
||||
selectedRepositoryId,
|
||||
onSelectedRepositoryChange,
|
||||
onRefreshLastSync,
|
||||
isRefreshingLastSync = false,
|
||||
isLoading = false,
|
||||
error,
|
||||
}: ForgejoIssueMetricCardsProps) {
|
||||
|
|
@ -251,16 +326,24 @@ export function ForgejoIssueMetricCards({
|
|||
};
|
||||
}, []);
|
||||
|
||||
const activeRepositories = metricRepositories ?? repositories;
|
||||
const openIssues = metrics?.open_issues ?? 0;
|
||||
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
||||
const staleOpen = metrics?.stale_open_issues ?? 0;
|
||||
const repositoriesSynced = metrics?.repositories_synced ?? repositories.length;
|
||||
const repositoriesSynced = metrics?.repositories_synced ?? activeRepositories.length;
|
||||
|
||||
// Stale: 0 → slate, 1–5 → amber, >5 → danger
|
||||
const staleTone: MetricTone =
|
||||
staleOpen === 0 ? "slate" :
|
||||
staleOpen <= 5 ? "amber" :
|
||||
"danger";
|
||||
const issueHref = (params: string): string => {
|
||||
const repositoryParam =
|
||||
selectedRepositoryId === ALL_REPOSITORIES_VALUE
|
||||
? ""
|
||||
: `&repository_id=${encodeURIComponent(selectedRepositoryId)}`;
|
||||
return `/git-projects/issues?${params}${repositoryParam}`;
|
||||
};
|
||||
|
||||
const cards: MetricCard[] = [
|
||||
{
|
||||
|
|
@ -270,7 +353,7 @@ export function ForgejoIssueMetricCards({
|
|||
openIssues === 0
|
||||
? "No open Git Project issues."
|
||||
: "Review open issues across Git Projects.",
|
||||
href: "/git-projects/issues?state=open",
|
||||
href: issueHref("state=open"),
|
||||
tone: openIssues > 0 ? "amber" : "muted",
|
||||
icon: openIssues > 0 ? AlertCircle : CheckCircle2,
|
||||
},
|
||||
|
|
@ -278,7 +361,7 @@ export function ForgejoIssueMetricCards({
|
|||
title: "Recently Closed",
|
||||
value: formatCount(recentlyClosed),
|
||||
caption: "Closed in the last 7 days.",
|
||||
href: "/git-projects/issues?state=closed&recent=7d",
|
||||
href: issueHref("state=closed&recent=7d"),
|
||||
tone: recentlyClosed > 0 ? "success" : "muted",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
|
|
@ -289,11 +372,17 @@ export function ForgejoIssueMetricCards({
|
|||
staleOpen === 0
|
||||
? "Nothing abandoned right now."
|
||||
: "Open issues without recent movement.",
|
||||
href: "/git-projects/issues?state=open&stale=1",
|
||||
href: issueHref("state=open&stale=1"),
|
||||
tone: staleTone,
|
||||
icon: staleOpen === 0 ? CheckCircle2 : Clock3,
|
||||
},
|
||||
buildSyncHealthCard(metrics, repositories, nowMs),
|
||||
buildSyncHealthCard(
|
||||
metrics,
|
||||
activeRepositories,
|
||||
nowMs,
|
||||
onRefreshLastSync,
|
||||
isRefreshingLastSync,
|
||||
),
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -307,13 +396,36 @@ export function ForgejoIssueMetricCards({
|
|||
High-level Forgejo issue health across repositories synced into Pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/git-projects/issues"
|
||||
className="inline-flex w-fit items-center gap-1 text-sm font-medium text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open issues
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<Select
|
||||
value={selectedRepositoryId}
|
||||
onValueChange={onSelectedRepositoryChange}
|
||||
disabled={repositories.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-[190px] rounded-lg border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 text-sm shadow-none focus:ring-offset-0 sm:w-[230px]">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value={ALL_REPOSITORIES_VALUE}>All projects</SelectItem>
|
||||
{repositories.map((repository) => (
|
||||
<SelectItem key={repository.id} value={repository.id}>
|
||||
{repositoryLabel(repository)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Link
|
||||
href={
|
||||
selectedRepositoryId === ALL_REPOSITORIES_VALUE
|
||||
? "/git-projects/issues"
|
||||
: `/git-projects/issues?repository_id=${encodeURIComponent(selectedRepositoryId)}`
|
||||
}
|
||||
className="inline-flex w-fit items-center gap-1 text-sm font-medium text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open issues
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning banner — left accent border, brighter amber */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue