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";
|
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 { useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
|
@ -75,6 +81,7 @@ import {
|
||||||
getForgejoLastPush,
|
getForgejoLastPush,
|
||||||
getForgejoMetrics,
|
getForgejoMetrics,
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
|
syncRepository,
|
||||||
type ForgejoHeatmapDay,
|
type ForgejoHeatmapDay,
|
||||||
type ForgejoIssueMetrics,
|
type ForgejoIssueMetrics,
|
||||||
type ForgejoRepository,
|
type ForgejoRepository,
|
||||||
|
|
@ -119,6 +126,7 @@ type GatewaySnapshot = GatewayTarget & {
|
||||||
|
|
||||||
const DASH = "—";
|
const DASH = "—";
|
||||||
const FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS = 5 * 60 * 1000;
|
const FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
const ALL_FORGEJO_REPOSITORIES = "all";
|
||||||
const DASHBOARD_RANGE = "7d";
|
const DASHBOARD_RANGE = "7d";
|
||||||
const DASHBOARD_RANGE_DAYS = 7;
|
const DASHBOARD_RANGE_DAYS = 7;
|
||||||
const DASHBOARD_RANGE_LABEL = "7 days";
|
const DASHBOARD_RANGE_LABEL = "7 days";
|
||||||
|
|
@ -429,6 +437,9 @@ const toSessionSummaries = (
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
const [selectedForgejoRepositoryId, setSelectedForgejoRepositoryId] =
|
||||||
|
useState(ALL_FORGEJO_REPOSITORIES);
|
||||||
|
const [isRefreshingForgejoSync, setIsRefreshingForgejoSync] = useState(false);
|
||||||
|
|
||||||
const boardsQuery = useListBoardsApiV1BoardsGet<
|
const boardsQuery = useListBoardsApiV1BoardsGet<
|
||||||
listBoardsApiV1BoardsGetResponse,
|
listBoardsApiV1BoardsGetResponse,
|
||||||
|
|
@ -502,6 +513,24 @@ export default function DashboardPage() {
|
||||||
() => forgejoRepositoriesQuery.data ?? [],
|
() => forgejoRepositoriesQuery.data ?? [],
|
||||||
[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(
|
const forgejoOrganizationId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
forgejoRepositories.find((repository) => repository.organization_id)
|
forgejoRepositories.find((repository) => repository.organization_id)
|
||||||
|
|
@ -509,16 +538,42 @@ export default function DashboardPage() {
|
||||||
[forgejoRepositories],
|
[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>({
|
const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({
|
||||||
queryKey: ["dashboard", "forgejo", "metrics", forgejoOrganizationId],
|
queryKey: [
|
||||||
|
"dashboard",
|
||||||
|
"forgejo",
|
||||||
|
"metrics",
|
||||||
|
forgejoOrganizationId,
|
||||||
|
selectedForgejoRepositoryId,
|
||||||
|
],
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
isSignedIn &&
|
isSignedIn &&
|
||||||
!forgejoRepositoriesQuery.isLoading &&
|
!forgejoRepositoriesQuery.isLoading &&
|
||||||
!forgejoRepositoriesQuery.error,
|
!forgejoRepositoriesQuery.error &&
|
||||||
|
(
|
||||||
|
selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
|
||||||
|
? forgejoOrganizationId
|
||||||
|
: selectedForgejoRepository
|
||||||
|
),
|
||||||
),
|
),
|
||||||
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
|
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
|
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
|
||||||
|
return getForgejoMetrics({ repository_id: selectedForgejoRepositoryId });
|
||||||
|
}
|
||||||
if (!forgejoOrganizationId) return Promise.resolve(null);
|
if (!forgejoOrganizationId) return Promise.resolve(null);
|
||||||
return getForgejoMetrics({ organization_id: forgejoOrganizationId });
|
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(
|
const boards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
boardsQuery.data?.status === 200
|
boardsQuery.data?.status === 200
|
||||||
|
|
@ -1238,6 +1321,11 @@ export default function DashboardPage() {
|
||||||
<ForgejoIssueMetricCards
|
<ForgejoIssueMetricCards
|
||||||
metrics={forgejoIssueMetrics}
|
metrics={forgejoIssueMetrics}
|
||||||
repositories={forgejoRepositories}
|
repositories={forgejoRepositories}
|
||||||
|
metricRepositories={scopedForgejoRepositories}
|
||||||
|
selectedRepositoryId={selectedForgejoRepositoryId}
|
||||||
|
onSelectedRepositoryChange={setSelectedForgejoRepositoryId}
|
||||||
|
onRefreshLastSync={handleRefreshForgejoLastSync}
|
||||||
|
isRefreshingLastSync={isRefreshingForgejoSync}
|
||||||
isLoading={forgejoIssueMetricsLoading}
|
isLoading={forgejoIssueMetricsLoading}
|
||||||
error={forgejoIssueMetricsError}
|
error={forgejoIssueMetricsError}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,22 @@ import {
|
||||||
|
|
||||||
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
|
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
type ForgejoIssueMetricCardsProps = {
|
type ForgejoIssueMetricCardsProps = {
|
||||||
metrics: ForgejoIssueMetrics | null;
|
metrics: ForgejoIssueMetrics | null;
|
||||||
repositories: ForgejoRepository[];
|
repositories: ForgejoRepository[];
|
||||||
|
metricRepositories?: ForgejoRepository[];
|
||||||
|
selectedRepositoryId: string;
|
||||||
|
onSelectedRepositoryChange: (repositoryId: string) => void;
|
||||||
|
onRefreshLastSync: () => void;
|
||||||
|
isRefreshingLastSync?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -33,10 +45,16 @@ type MetricCard = {
|
||||||
href: string;
|
href: string;
|
||||||
tone: MetricTone;
|
tone: MetricTone;
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat("en-US");
|
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||||
const STALE_SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
const STALE_SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const ALL_REPOSITORIES_VALUE = "all";
|
||||||
|
|
||||||
const formatCount = (value: number | null | undefined): string =>
|
const formatCount = (value: number | null | undefined): string =>
|
||||||
numberFormatter.format(Math.max(0, Math.round(Number(value ?? 0))));
|
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) ────────────────────────────
|
// ── Tone → icon badge (background + text color) ────────────────────────────
|
||||||
const toneIconClasses: Record<MetricTone, string> = {
|
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]",
|
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,
|
metrics: ForgejoIssueMetrics | null,
|
||||||
repositories: ForgejoRepository[],
|
repositories: ForgejoRepository[],
|
||||||
nowMs: number | null,
|
nowMs: number | null,
|
||||||
|
onRefreshLastSync: () => void,
|
||||||
|
isRefreshingLastSync: boolean,
|
||||||
): MetricCard {
|
): MetricCard {
|
||||||
const repositoryCount = repositories.length;
|
const repositoryCount = repositories.length;
|
||||||
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
||||||
|
|
@ -122,7 +145,7 @@ function buildSyncHealthCard(
|
||||||
|
|
||||||
if (repositoryCount === 0) {
|
if (repositoryCount === 0) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync",
|
||||||
value: "No repos",
|
value: "No repos",
|
||||||
caption: "Add repositories to track sync status.",
|
caption: "Add repositories to track sync status.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
|
|
@ -132,41 +155,61 @@ function buildSyncHealthCard(
|
||||||
}
|
}
|
||||||
if (syncErrorCount > 0) {
|
if (syncErrorCount > 0) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync",
|
||||||
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
|
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
|
||||||
caption: "Repository sync needs attention.",
|
caption: "Repository sync needs attention.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
icon: ShieldAlert,
|
icon: ShieldAlert,
|
||||||
|
action: {
|
||||||
|
label: "Refresh Last Sync",
|
||||||
|
isLoading: isRefreshingLastSync,
|
||||||
|
onClick: onRefreshLastSync,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!latestSync) {
|
if (!latestSync) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync",
|
||||||
value: "Waiting",
|
value: "Waiting",
|
||||||
caption: "Repositories have not synced yet.",
|
caption: "Repositories have not synced yet.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "slate",
|
tone: "slate",
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
|
action: {
|
||||||
|
label: "Refresh Last Sync",
|
||||||
|
isLoading: isRefreshingLastSync,
|
||||||
|
onClick: onRefreshLastSync,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
|
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync",
|
||||||
value: "Stale",
|
value: "Stale",
|
||||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "amber",
|
tone: "amber",
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
|
action: {
|
||||||
|
label: "Refresh Last Sync",
|
||||||
|
isLoading: isRefreshingLastSync,
|
||||||
|
onClick: onRefreshLastSync,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: "Last Sync Health",
|
title: "Last Sync",
|
||||||
value: "Healthy",
|
value: "Healthy",
|
||||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects/repositories",
|
||||||
tone: "cyan",
|
tone: "cyan",
|
||||||
icon: ShieldCheck,
|
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);
|
const valueParts = card.value.split(/(\d[\d,]*)/g);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
href={card.href}
|
|
||||||
className={cn(
|
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],
|
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">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
|
||||||
{card.title}
|
{card.title}
|
||||||
|
|
@ -216,20 +263,43 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex shrink-0 items-center gap-1.5 pointer-events-auto">
|
||||||
className={cn(
|
{card.action ? (
|
||||||
"shrink-0 rounded-lg border p-2 transition-transform duration-200 group-hover:scale-110",
|
<button
|
||||||
toneIconClasses[card.tone],
|
type="button"
|
||||||
)}
|
onClick={(event) => {
|
||||||
>
|
event.preventDefault();
|
||||||
<Icon className="h-4 w-4" />
|
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>
|
</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>
|
<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)]" />
|
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted transition group-hover:text-[color:var(--accent)]" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,6 +307,11 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
||||||
export function ForgejoIssueMetricCards({
|
export function ForgejoIssueMetricCards({
|
||||||
metrics,
|
metrics,
|
||||||
repositories,
|
repositories,
|
||||||
|
metricRepositories,
|
||||||
|
selectedRepositoryId,
|
||||||
|
onSelectedRepositoryChange,
|
||||||
|
onRefreshLastSync,
|
||||||
|
isRefreshingLastSync = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error,
|
error,
|
||||||
}: ForgejoIssueMetricCardsProps) {
|
}: ForgejoIssueMetricCardsProps) {
|
||||||
|
|
@ -251,16 +326,24 @@ export function ForgejoIssueMetricCards({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const activeRepositories = metricRepositories ?? repositories;
|
||||||
const openIssues = metrics?.open_issues ?? 0;
|
const openIssues = metrics?.open_issues ?? 0;
|
||||||
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
||||||
const staleOpen = metrics?.stale_open_issues ?? 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
|
// Stale: 0 → slate, 1–5 → amber, >5 → danger
|
||||||
const staleTone: MetricTone =
|
const staleTone: MetricTone =
|
||||||
staleOpen === 0 ? "slate" :
|
staleOpen === 0 ? "slate" :
|
||||||
staleOpen <= 5 ? "amber" :
|
staleOpen <= 5 ? "amber" :
|
||||||
"danger";
|
"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[] = [
|
const cards: MetricCard[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -270,7 +353,7 @@ export function ForgejoIssueMetricCards({
|
||||||
openIssues === 0
|
openIssues === 0
|
||||||
? "No open Git Project issues."
|
? "No open Git Project issues."
|
||||||
: "Review open issues across Git Projects.",
|
: "Review open issues across Git Projects.",
|
||||||
href: "/git-projects/issues?state=open",
|
href: issueHref("state=open"),
|
||||||
tone: openIssues > 0 ? "amber" : "muted",
|
tone: openIssues > 0 ? "amber" : "muted",
|
||||||
icon: openIssues > 0 ? AlertCircle : CheckCircle2,
|
icon: openIssues > 0 ? AlertCircle : CheckCircle2,
|
||||||
},
|
},
|
||||||
|
|
@ -278,7 +361,7 @@ export function ForgejoIssueMetricCards({
|
||||||
title: "Recently Closed",
|
title: "Recently Closed",
|
||||||
value: formatCount(recentlyClosed),
|
value: formatCount(recentlyClosed),
|
||||||
caption: "Closed in the last 7 days.",
|
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",
|
tone: recentlyClosed > 0 ? "success" : "muted",
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
|
|
@ -289,11 +372,17 @@ export function ForgejoIssueMetricCards({
|
||||||
staleOpen === 0
|
staleOpen === 0
|
||||||
? "Nothing abandoned right now."
|
? "Nothing abandoned right now."
|
||||||
: "Open issues without recent movement.",
|
: "Open issues without recent movement.",
|
||||||
href: "/git-projects/issues?state=open&stale=1",
|
href: issueHref("state=open&stale=1"),
|
||||||
tone: staleTone,
|
tone: staleTone,
|
||||||
icon: staleOpen === 0 ? CheckCircle2 : Clock3,
|
icon: staleOpen === 0 ? CheckCircle2 : Clock3,
|
||||||
},
|
},
|
||||||
buildSyncHealthCard(metrics, repositories, nowMs),
|
buildSyncHealthCard(
|
||||||
|
metrics,
|
||||||
|
activeRepositories,
|
||||||
|
nowMs,
|
||||||
|
onRefreshLastSync,
|
||||||
|
isRefreshingLastSync,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -307,13 +396,36 @@ export function ForgejoIssueMetricCards({
|
||||||
High-level Forgejo issue health across repositories synced into Pipeline.
|
High-level Forgejo issue health across repositories synced into Pipeline.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||||
href="/git-projects/issues"
|
<Select
|
||||||
className="inline-flex w-fit items-center gap-1 text-sm font-medium text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
value={selectedRepositoryId}
|
||||||
>
|
onValueChange={onSelectedRepositoryChange}
|
||||||
Open issues
|
disabled={repositories.length === 0}
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
>
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Warning banner — left accent border, brighter amber */}
|
{/* Warning banner — left accent border, brighter amber */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue