diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 3824443..1094612 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -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({ - 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() { diff --git a/frontend/src/components/git/ForgejoIssueMetricCards.tsx b/frontend/src/components/git/ForgejoIssueMetricCards.tsx index b8e7126..dfb495e 100644 --- a/frontend/src/components/git/ForgejoIssueMetricCards.tsx +++ b/frontend/src/components/git/ForgejoIssueMetricCards.tsx @@ -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 = { 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 ( - -
+ +

{card.title} @@ -216,20 +263,43 @@ function MetricCardLink({ card }: { card: MetricCard }) { )}

-
- +
+ {card.action ? ( + + ) : null} +
+ +
-
+

{card.caption}

- +
); } @@ -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.

- - Open issues - - +
+ + + Open issues + + +
{/* Warning banner — left accent border, brighter amber */}