|
1 | 1 | import { AlertTriangle, GitBranch, Squirrel } from 'lucide-react'; |
2 | | -import { useEffect, useRef, useState } from 'react'; |
| 2 | +import { useEffect, useState } from 'react'; |
3 | 3 | import { useParams } from 'react-router-dom'; |
4 | 4 | import { PageHeader } from '../../../components/layout/PageHeader'; |
5 | 5 | import { Button } from '../../../components/ui/Button'; |
6 | 6 | import { Card, CardBody, CardHeader } from '../../../components/ui/Card'; |
7 | 7 | import { EmptyState } from '../../../components/ui/EmptyState'; |
8 | | -import { Spinner } from '../../../components/ui/Spinner'; |
9 | 8 | import type { DashboardRange } from '../../../types/dashboard'; |
10 | 9 | import { PipelinesTable } from '../../pipelines/components/PipelinesTable'; |
11 | 10 | import { useRunners } from '../../runners/hooks/useRunners'; |
12 | 11 | import { ActivityChart } from '../components/ActivityChart'; |
13 | 12 | import { ActivityPanel } from '../components/ActivityPanel'; |
14 | 13 | import { KpiStrip } from '../components/KpiStrip'; |
| 14 | +import { PagerControls } from '../components/PagerControls'; |
15 | 15 | import { RunnerHealthPanel } from '../components/RunnerHealthPanel'; |
16 | 16 | import { SuccessRateChart } from '../components/SuccessRateChart'; |
17 | 17 | import { useDashboardActivity, useDashboardRecentPipelines, useDashboardSummary } from '../hooks/useDashboard'; |
@@ -50,30 +50,45 @@ export function DashboardPage() { |
50 | 50 | (summary.data?.pipelinesTotal ?? 0) === 0 && |
51 | 51 | (runners.data?.length ?? 0) === 0; |
52 | 52 |
|
53 | | - // Flatten the keyset pages for the full-width recent-pipelines table. |
| 53 | + // Flatten the keyset pages for the full-width recent-pipelines table, |
| 54 | + // then page through them five at a time — the next keyset page is fetched |
| 55 | + // on demand when the reader steps past what's already loaded. |
| 56 | + const PIPELINES_PAGE_SIZE = 5; |
54 | 57 | const pipelines = (recentPipelines.data?.pages ?? []).flatMap((page) => page.pipelines); |
55 | | - |
56 | | - // Infinite scroll: pull the next page when the sentinel scrolls into view. |
57 | | - const sentinelRef = useRef<HTMLDivElement | null>(null); |
| 58 | + const [pipelinePage, setPipelinePage] = useState(0); |
58 | 59 | const { |
59 | 60 | hasNextPage: pipelinesHasNext, |
60 | 61 | isFetchingNextPage: pipelinesFetchingNext, |
61 | 62 | fetchNextPage: fetchNextPipelines, |
62 | 63 | } = recentPipelines; |
| 64 | + |
| 65 | + const pipelineStart = pipelinePage * PIPELINES_PAGE_SIZE; |
| 66 | + const visiblePipelines = pipelines.slice(pipelineStart, pipelineStart + PIPELINES_PAGE_SIZE); |
| 67 | + const moreLoaded = pipelineStart + PIPELINES_PAGE_SIZE < pipelines.length; |
| 68 | + |
| 69 | + // Clamp when live updates shrink the loaded list. |
63 | 70 | useEffect(() => { |
64 | | - const sentinel = sentinelRef.current; |
65 | | - if (!sentinel || !pipelinesHasNext) return; |
66 | | - const observer = new IntersectionObserver( |
67 | | - (entries) => { |
68 | | - if (entries.some((entry) => entry.isIntersecting) && !pipelinesFetchingNext) { |
69 | | - void fetchNextPipelines(); |
| 71 | + const maxPage = Math.max(0, Math.ceil(pipelines.length / PIPELINES_PAGE_SIZE) - 1); |
| 72 | + if (pipelinePage > maxPage) setPipelinePage(maxPage); |
| 73 | + }, [pipelinePage, pipelines.length]); |
| 74 | + |
| 75 | + const nextPipelinePage = () => { |
| 76 | + if (moreLoaded) { |
| 77 | + setPipelinePage((current) => current + 1); |
| 78 | + return; |
| 79 | + } |
| 80 | + if (pipelinesHasNext && !pipelinesFetchingNext) { |
| 81 | + void fetchNextPipelines().then((result) => { |
| 82 | + const loaded = (result.data?.pages ?? []).reduce( |
| 83 | + (count, page) => count + page.pipelines.length, |
| 84 | + 0, |
| 85 | + ); |
| 86 | + if (pipelineStart + PIPELINES_PAGE_SIZE < loaded) { |
| 87 | + setPipelinePage((current) => current + 1); |
70 | 88 | } |
71 | | - }, |
72 | | - { rootMargin: '200px' }, |
73 | | - ); |
74 | | - observer.observe(sentinel); |
75 | | - return () => observer.disconnect(); |
76 | | - }, [pipelinesHasNext, pipelinesFetchingNext, fetchNextPipelines]); |
| 89 | + }); |
| 90 | + } |
| 91 | + }; |
77 | 92 |
|
78 | 93 | return ( |
79 | 94 | <> |
@@ -151,17 +166,19 @@ export function DashboardPage() { |
151 | 166 | /> |
152 | 167 | ) : ( |
153 | 168 | <> |
154 | | - <PipelinesTable slug={slug} pipelines={pipelines} /> |
155 | | - {pipelinesHasNext && ( |
156 | | - <div ref={sentinelRef} className="mt-4 flex justify-center"> |
157 | | - {pipelinesFetchingNext ? ( |
158 | | - <Spinner className="h-5 w-5 text-steel" /> |
159 | | - ) : ( |
160 | | - <Button size="sm" variant="secondary" onClick={() => void fetchNextPipelines()}> |
161 | | - Load more |
162 | | - </Button> |
163 | | - )} |
164 | | - </div> |
| 169 | + <PipelinesTable slug={slug} pipelines={visiblePipelines} /> |
| 170 | + {(pipelines.length > PIPELINES_PAGE_SIZE || pipelinesHasNext) && ( |
| 171 | + <PagerControls |
| 172 | + label={`${pipelineStart + 1}–${Math.min( |
| 173 | + pipelineStart + PIPELINES_PAGE_SIZE, |
| 174 | + pipelines.length, |
| 175 | + )}${pipelinesHasNext ? '' : ` of ${pipelines.length}`}`} |
| 176 | + hasPrev={pipelinePage > 0} |
| 177 | + hasNext={moreLoaded || pipelinesHasNext} |
| 178 | + loadingNext={pipelinesFetchingNext} |
| 179 | + onPrev={() => setPipelinePage((current) => Math.max(0, current - 1))} |
| 180 | + onNext={nextPipelinePage} |
| 181 | + /> |
165 | 182 | )} |
166 | 183 | </> |
167 | 184 | )} |
|
0 commit comments