Skip to content

Commit 00a8c53

Browse files
authored
Merge pull request #34 from BotCoder254/feat/log-chunk-sections
Dashboard: 5-per-page pagination for Runner health + Recent pipelines; frontend-only CI
2 parents 1bd9e51 + 2d7d941 commit 00a8c53

4 files changed

Lines changed: 131 additions & 113 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
name: CI
22

3-
# Compile + test gate for the whole monorepo: the React/TypeScript frontend
4-
# and the three sibling Rust crates (protocol, backend, runner). Every job is
5-
# self-contained — no database, no secrets, no external services — because the
6-
# backend uses runtime sqlx queries (no compile-time `query!` macros), so the
7-
# control plane builds and unit-tests without a live PostgreSQL.
3+
# Compile + test gate for the React/TypeScript frontend. The Rust crates and
4+
# the GHCR runner-image publish were deliberately removed from CI — Rust is
5+
# verified locally (cargo check / clippy -D warnings / cargo test per crate)
6+
# and the runner image is published manually per
7+
# docs/fix-empty-workspace-stale-runner.md.
88

99
on:
1010
push:
@@ -20,9 +20,6 @@ concurrency:
2020
permissions:
2121
contents: read
2222

23-
env:
24-
CARGO_TERM_COLOR: always
25-
2623
jobs:
2724
# --- Frontend: React + TypeScript (create-react-app) ----------------------
2825
frontend:
@@ -57,77 +54,3 @@ jobs:
5754
run: npm run build
5855
env:
5956
CI: false
60-
61-
# --- Rust crates: protocol, backend, runner (edition 2024) ----------------
62-
rust:
63-
name: rust
64-
runs-on: ubuntu-latest
65-
strategy:
66-
fail-fast: false
67-
matrix:
68-
crate: [protocol, backend, runner]
69-
defaults:
70-
run:
71-
working-directory: ${{ matrix.crate }}
72-
steps:
73-
- uses: actions/checkout@v4
74-
75-
- name: Install stable Rust toolchain
76-
uses: dtolnay/rust-toolchain@stable
77-
78-
# Per-crate cache keyed on each crate's own Cargo.lock (no shared
79-
# workspace) so the three builds don't clobber each other's cache.
80-
- name: Cache cargo build
81-
uses: Swatinem/rust-cache@v2
82-
with:
83-
workspaces: ${{ matrix.crate }}
84-
85-
- name: Build
86-
run: cargo build --locked --verbose
87-
88-
- name: Test
89-
run: cargo test --locked --verbose
90-
91-
# --- Runner image: build + publish to GHCR on main -------------------------
92-
# The hosted-runner provisioner pulls ghcr.io/botcoder254/overup-runner:latest
93-
# (the RUNNER_IMAGE default) — this job is what actually ships runner fixes
94-
# to deployments. ONE-TIME SETUP: after the first push, make the GHCR
95-
# package PUBLIC (github.com/botcoder254 → Packages → overup-runner →
96-
# Package settings → Change visibility), or anonymous pulls are denied and
97-
# deployments fall back to whatever image is already on their daemon.
98-
# Existing runner containers keep their old image: after a publish, pull the
99-
# new tag on the host and revoke + recreate the hosted runners.
100-
runner-image:
101-
name: runner image (publish to GHCR)
102-
runs-on: ubuntu-latest
103-
needs: rust
104-
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
105-
permissions:
106-
contents: read
107-
packages: write
108-
steps:
109-
- uses: actions/checkout@v4
110-
111-
- name: Set up Docker Buildx
112-
uses: docker/setup-buildx-action@v3
113-
114-
- name: Log in to GHCR
115-
uses: docker/login-action@v3
116-
with:
117-
registry: ghcr.io
118-
username: ${{ github.actor }}
119-
password: ${{ secrets.GITHUB_TOKEN }}
120-
121-
# Build context is the repository root — runner/Dockerfile needs the
122-
# sibling protocol/ crate in the context (see the Dockerfile header).
123-
- name: Build and push
124-
uses: docker/build-push-action@v6
125-
with:
126-
context: .
127-
file: runner/Dockerfile
128-
push: true
129-
tags: |
130-
ghcr.io/botcoder254/overup-runner:latest
131-
ghcr.io/botcoder254/overup-runner:${{ github.sha }}
132-
cache-from: type=gha
133-
cache-to: type=gha,mode=max
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ChevronLeft, ChevronRight } from 'lucide-react';
2+
import { Button } from '../../../components/ui/Button';
3+
4+
interface PagerControlsProps {
5+
/** e.g. "1–5 of 12" — rendered in the muted steel caption style. */
6+
label: string;
7+
hasPrev: boolean;
8+
hasNext: boolean;
9+
onPrev: () => void;
10+
onNext: () => void;
11+
/** True while the next keyset page is being fetched. */
12+
loadingNext?: boolean;
13+
}
14+
15+
/**
16+
* Compact page-by-page controls for the dashboard panels (5 rows per page).
17+
* Same control language as the rest of the shell: small secondary buttons,
18+
* steel caption text.
19+
*/
20+
export function PagerControls({
21+
label,
22+
hasPrev,
23+
hasNext,
24+
onPrev,
25+
onNext,
26+
loadingNext,
27+
}: PagerControlsProps) {
28+
return (
29+
<div className="mt-3 flex items-center justify-between gap-2">
30+
<span className="text-xs text-steel">{label}</span>
31+
<div className="flex items-center gap-1">
32+
<Button size="sm" variant="secondary" disabled={!hasPrev} onClick={onPrev}>
33+
<ChevronLeft size={14} aria-hidden="true" />
34+
Prev
35+
</Button>
36+
<Button
37+
size="sm"
38+
variant="secondary"
39+
disabled={!hasNext}
40+
isLoading={loadingNext}
41+
onClick={onNext}
42+
>
43+
Next
44+
<ChevronRight size={14} aria-hidden="true" />
45+
</Button>
46+
</div>
47+
</div>
48+
);
49+
}

‎src/features/dashboard/components/RunnerHealthPanel.tsx‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
11
import { formatDistanceToNow } from 'date-fns';
22
import { Server } from 'lucide-react';
3+
import { useEffect, useState } from 'react';
34
import { Link } from 'react-router-dom';
45
import { workspacePath } from '../../../app/navigation';
56
import { EmptyState } from '../../../components/ui/EmptyState';
67
import { RunnerStatusBadge } from '../../runners/components/RunnerStatusBadge';
78
import type { Runner } from '../../../types/runner';
9+
import { PagerControls } from './PagerControls';
10+
11+
const PAGE_SIZE = 5;
812

913
interface RunnerHealthPanelProps {
1014
slug: string;
1115
runners: Runner[];
1216
loading: boolean;
1317
}
1418

15-
/** Compact runner roster for the Dashboard's side panel — status, CPU, last seen. */
19+
/**
20+
* Compact runner roster for the Dashboard's side panel — status, CPU, last
21+
* seen; paged five at a time so a large fleet doesn't stretch the panel.
22+
*/
1623
export function RunnerHealthPanel({ slug, runners, loading }: RunnerHealthPanelProps) {
24+
const [page, setPage] = useState(0);
25+
const pageCount = Math.max(1, Math.ceil(runners.length / PAGE_SIZE));
26+
27+
// Clamp when the roster shrinks (revocation, live updates).
28+
useEffect(() => {
29+
if (page > pageCount - 1) setPage(pageCount - 1);
30+
}, [page, pageCount]);
31+
1732
if (loading) {
1833
return <div className="h-40 animate-pulse rounded border border-steel/20 bg-canvas" />;
1934
}
@@ -28,9 +43,13 @@ export function RunnerHealthPanel({ slug, runners, loading }: RunnerHealthPanelP
2843
);
2944
}
3045

46+
const start = page * PAGE_SIZE;
47+
const visible = runners.slice(start, start + PAGE_SIZE);
48+
3149
return (
50+
<>
3251
<div className="divide-y divide-steel/10 rounded border border-steel/20 bg-canvas">
33-
{runners.map((runner) => {
52+
{visible.map((runner) => {
3453
const cpuPermille = runner.lastHealth?.cpuPermille;
3554
const cpuPct = cpuPermille !== undefined ? Math.min(100, Math.round(cpuPermille / 10)) : null;
3655
return (
@@ -57,5 +76,15 @@ export function RunnerHealthPanel({ slug, runners, loading }: RunnerHealthPanelP
5776
);
5877
})}
5978
</div>
79+
{runners.length > PAGE_SIZE && (
80+
<PagerControls
81+
label={`${start + 1}–${Math.min(start + PAGE_SIZE, runners.length)} of ${runners.length}`}
82+
hasPrev={page > 0}
83+
hasNext={start + PAGE_SIZE < runners.length}
84+
onPrev={() => setPage((current) => Math.max(0, current - 1))}
85+
onNext={() => setPage((current) => current + 1)}
86+
/>
87+
)}
88+
</>
6089
);
6190
}

‎src/features/dashboard/pages/DashboardPage.tsx‎

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { AlertTriangle, GitBranch, Squirrel } from 'lucide-react';
2-
import { useEffect, useRef, useState } from 'react';
2+
import { useEffect, useState } from 'react';
33
import { useParams } from 'react-router-dom';
44
import { PageHeader } from '../../../components/layout/PageHeader';
55
import { Button } from '../../../components/ui/Button';
66
import { Card, CardBody, CardHeader } from '../../../components/ui/Card';
77
import { EmptyState } from '../../../components/ui/EmptyState';
8-
import { Spinner } from '../../../components/ui/Spinner';
98
import type { DashboardRange } from '../../../types/dashboard';
109
import { PipelinesTable } from '../../pipelines/components/PipelinesTable';
1110
import { useRunners } from '../../runners/hooks/useRunners';
1211
import { ActivityChart } from '../components/ActivityChart';
1312
import { ActivityPanel } from '../components/ActivityPanel';
1413
import { KpiStrip } from '../components/KpiStrip';
14+
import { PagerControls } from '../components/PagerControls';
1515
import { RunnerHealthPanel } from '../components/RunnerHealthPanel';
1616
import { SuccessRateChart } from '../components/SuccessRateChart';
1717
import { useDashboardActivity, useDashboardRecentPipelines, useDashboardSummary } from '../hooks/useDashboard';
@@ -50,30 +50,45 @@ export function DashboardPage() {
5050
(summary.data?.pipelinesTotal ?? 0) === 0 &&
5151
(runners.data?.length ?? 0) === 0;
5252

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;
5457
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);
5859
const {
5960
hasNextPage: pipelinesHasNext,
6061
isFetchingNextPage: pipelinesFetchingNext,
6162
fetchNextPage: fetchNextPipelines,
6263
} = 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.
6370
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);
7088
}
71-
},
72-
{ rootMargin: '200px' },
73-
);
74-
observer.observe(sentinel);
75-
return () => observer.disconnect();
76-
}, [pipelinesHasNext, pipelinesFetchingNext, fetchNextPipelines]);
89+
});
90+
}
91+
};
7792

7893
return (
7994
<>
@@ -151,17 +166,19 @@ export function DashboardPage() {
151166
/>
152167
) : (
153168
<>
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+
/>
165182
)}
166183
</>
167184
)}

0 commit comments

Comments
 (0)