Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 5 additions & 82 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: CI

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

on:
push:
Expand All @@ -20,9 +20,6 @@ concurrency:
permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
# --- Frontend: React + TypeScript (create-react-app) ----------------------
frontend:
Expand Down Expand Up @@ -57,77 +54,3 @@ jobs:
run: npm run build
env:
CI: false

# --- Rust crates: protocol, backend, runner (edition 2024) ----------------
rust:
name: rust
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
crate: [protocol, backend, runner]
defaults:
run:
working-directory: ${{ matrix.crate }}
steps:
- uses: actions/checkout@v4

- name: Install stable Rust toolchain
uses: dtolnay/rust-toolchain@stable

# Per-crate cache keyed on each crate's own Cargo.lock (no shared
# workspace) so the three builds don't clobber each other's cache.
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
workspaces: ${{ matrix.crate }}

- name: Build
run: cargo build --locked --verbose

- name: Test
run: cargo test --locked --verbose

# --- Runner image: build + publish to GHCR on main -------------------------
# The hosted-runner provisioner pulls ghcr.io/botcoder254/overup-runner:latest
# (the RUNNER_IMAGE default) — this job is what actually ships runner fixes
# to deployments. ONE-TIME SETUP: after the first push, make the GHCR
# package PUBLIC (github.com/botcoder254 → Packages → overup-runner →
# Package settings → Change visibility), or anonymous pulls are denied and
# deployments fall back to whatever image is already on their daemon.
# Existing runner containers keep their old image: after a publish, pull the
# new tag on the host and revoke + recreate the hosted runners.
runner-image:
name: runner image (publish to GHCR)
runs-on: ubuntu-latest
needs: rust
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Build context is the repository root — runner/Dockerfile needs the
# sibling protocol/ crate in the context (see the Dockerfile header).
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: runner/Dockerfile
push: true
tags: |
ghcr.io/botcoder254/overup-runner:latest
ghcr.io/botcoder254/overup-runner:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
49 changes: 49 additions & 0 deletions src/features/dashboard/components/PagerControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '../../../components/ui/Button';

interface PagerControlsProps {
/** e.g. "1–5 of 12" — rendered in the muted steel caption style. */
label: string;
hasPrev: boolean;
hasNext: boolean;
onPrev: () => void;
onNext: () => void;
/** True while the next keyset page is being fetched. */
loadingNext?: boolean;
}

/**
* Compact page-by-page controls for the dashboard panels (5 rows per page).
* Same control language as the rest of the shell: small secondary buttons,
* steel caption text.
*/
export function PagerControls({
label,
hasPrev,
hasNext,
onPrev,
onNext,
loadingNext,
}: PagerControlsProps) {
return (
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-xs text-steel">{label}</span>
<div className="flex items-center gap-1">
<Button size="sm" variant="secondary" disabled={!hasPrev} onClick={onPrev}>
<ChevronLeft size={14} aria-hidden="true" />
Prev
</Button>
<Button
size="sm"
variant="secondary"
disabled={!hasNext}
isLoading={loadingNext}
onClick={onNext}
>
Next
<ChevronRight size={14} aria-hidden="true" />
</Button>
</div>
</div>
);
}
33 changes: 31 additions & 2 deletions src/features/dashboard/components/RunnerHealthPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
import { formatDistanceToNow } from 'date-fns';
import { Server } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { workspacePath } from '../../../app/navigation';
import { EmptyState } from '../../../components/ui/EmptyState';
import { RunnerStatusBadge } from '../../runners/components/RunnerStatusBadge';
import type { Runner } from '../../../types/runner';
import { PagerControls } from './PagerControls';

const PAGE_SIZE = 5;

interface RunnerHealthPanelProps {
slug: string;
runners: Runner[];
loading: boolean;
}

/** Compact runner roster for the Dashboard's side panel — status, CPU, last seen. */
/**
* Compact runner roster for the Dashboard's side panel — status, CPU, last
* seen; paged five at a time so a large fleet doesn't stretch the panel.
*/
export function RunnerHealthPanel({ slug, runners, loading }: RunnerHealthPanelProps) {
const [page, setPage] = useState(0);
const pageCount = Math.max(1, Math.ceil(runners.length / PAGE_SIZE));

// Clamp when the roster shrinks (revocation, live updates).
useEffect(() => {
if (page > pageCount - 1) setPage(pageCount - 1);
}, [page, pageCount]);

if (loading) {
return <div className="h-40 animate-pulse rounded border border-steel/20 bg-canvas" />;
}
Expand All @@ -28,9 +43,13 @@ export function RunnerHealthPanel({ slug, runners, loading }: RunnerHealthPanelP
);
}

const start = page * PAGE_SIZE;
const visible = runners.slice(start, start + PAGE_SIZE);

return (
<>
<div className="divide-y divide-steel/10 rounded border border-steel/20 bg-canvas">
{runners.map((runner) => {
{visible.map((runner) => {
const cpuPermille = runner.lastHealth?.cpuPermille;
const cpuPct = cpuPermille !== undefined ? Math.min(100, Math.round(cpuPermille / 10)) : null;
return (
Expand All @@ -57,5 +76,15 @@ export function RunnerHealthPanel({ slug, runners, loading }: RunnerHealthPanelP
);
})}
</div>
{runners.length > PAGE_SIZE && (
<PagerControls
label={`${start + 1}–${Math.min(start + PAGE_SIZE, runners.length)} of ${runners.length}`}
hasPrev={page > 0}
hasNext={start + PAGE_SIZE < runners.length}
onPrev={() => setPage((current) => Math.max(0, current - 1))}
onNext={() => setPage((current) => current + 1)}
/>
)}
</>
);
}
75 changes: 46 additions & 29 deletions src/features/dashboard/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { AlertTriangle, GitBranch, Squirrel } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { PageHeader } from '../../../components/layout/PageHeader';
import { Button } from '../../../components/ui/Button';
import { Card, CardBody, CardHeader } from '../../../components/ui/Card';
import { EmptyState } from '../../../components/ui/EmptyState';
import { Spinner } from '../../../components/ui/Spinner';
import type { DashboardRange } from '../../../types/dashboard';
import { PipelinesTable } from '../../pipelines/components/PipelinesTable';
import { useRunners } from '../../runners/hooks/useRunners';
import { ActivityChart } from '../components/ActivityChart';
import { ActivityPanel } from '../components/ActivityPanel';
import { KpiStrip } from '../components/KpiStrip';
import { PagerControls } from '../components/PagerControls';
import { RunnerHealthPanel } from '../components/RunnerHealthPanel';
import { SuccessRateChart } from '../components/SuccessRateChart';
import { useDashboardActivity, useDashboardRecentPipelines, useDashboardSummary } from '../hooks/useDashboard';
Expand Down Expand Up @@ -50,30 +50,45 @@ export function DashboardPage() {
(summary.data?.pipelinesTotal ?? 0) === 0 &&
(runners.data?.length ?? 0) === 0;

// Flatten the keyset pages for the full-width recent-pipelines table.
// Flatten the keyset pages for the full-width recent-pipelines table,
// then page through them five at a time — the next keyset page is fetched
// on demand when the reader steps past what's already loaded.
const PIPELINES_PAGE_SIZE = 5;
const pipelines = (recentPipelines.data?.pages ?? []).flatMap((page) => page.pipelines);

// Infinite scroll: pull the next page when the sentinel scrolls into view.
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [pipelinePage, setPipelinePage] = useState(0);
const {
hasNextPage: pipelinesHasNext,
isFetchingNextPage: pipelinesFetchingNext,
fetchNextPage: fetchNextPipelines,
} = recentPipelines;

const pipelineStart = pipelinePage * PIPELINES_PAGE_SIZE;
const visiblePipelines = pipelines.slice(pipelineStart, pipelineStart + PIPELINES_PAGE_SIZE);
const moreLoaded = pipelineStart + PIPELINES_PAGE_SIZE < pipelines.length;

// Clamp when live updates shrink the loaded list.
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !pipelinesHasNext) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting) && !pipelinesFetchingNext) {
void fetchNextPipelines();
const maxPage = Math.max(0, Math.ceil(pipelines.length / PIPELINES_PAGE_SIZE) - 1);
if (pipelinePage > maxPage) setPipelinePage(maxPage);
}, [pipelinePage, pipelines.length]);

const nextPipelinePage = () => {
if (moreLoaded) {
setPipelinePage((current) => current + 1);
return;
}
if (pipelinesHasNext && !pipelinesFetchingNext) {
void fetchNextPipelines().then((result) => {
const loaded = (result.data?.pages ?? []).reduce(
(count, page) => count + page.pipelines.length,
0,
);
if (pipelineStart + PIPELINES_PAGE_SIZE < loaded) {
setPipelinePage((current) => current + 1);
}
Comment on lines +81 to 88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Logic Error: pipelineStart is captured in the closure but may be stale when the promise resolves. If the user changes pages during the fetch, the comparison uses an outdated value, potentially advancing to the wrong page.

Suggested change
void fetchNextPipelines().then((result) => {
const loaded = (result.data?.pages ?? []).reduce(
(count, page) => count + page.pipelines.length,
0,
);
if (pipelineStart + PIPELINES_PAGE_SIZE < loaded) {
setPipelinePage((current) => current + 1);
}
void fetchNextPipelines().then((result) => {
const loaded = (result.data?.pages ?? []).reduce(
(count, page) => count + page.pipelines.length,
0,
);
const currentStart = pipelinePage * PIPELINES_PAGE_SIZE;
if (currentStart + PIPELINES_PAGE_SIZE < loaded) {
setPipelinePage((current) => current + 1);
}
});

},
{ rootMargin: '200px' },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [pipelinesHasNext, pipelinesFetchingNext, fetchNextPipelines]);
});
}
};

return (
<>
Expand Down Expand Up @@ -151,17 +166,19 @@ export function DashboardPage() {
/>
) : (
<>
<PipelinesTable slug={slug} pipelines={pipelines} />
{pipelinesHasNext && (
<div ref={sentinelRef} className="mt-4 flex justify-center">
{pipelinesFetchingNext ? (
<Spinner className="h-5 w-5 text-steel" />
) : (
<Button size="sm" variant="secondary" onClick={() => void fetchNextPipelines()}>
Load more
</Button>
)}
</div>
<PipelinesTable slug={slug} pipelines={visiblePipelines} />
{(pipelines.length > PIPELINES_PAGE_SIZE || pipelinesHasNext) && (
<PagerControls
label={`${pipelineStart + 1}–${Math.min(
pipelineStart + PIPELINES_PAGE_SIZE,
pipelines.length,
)}${pipelinesHasNext ? '' : ` of ${pipelines.length}`}`}
hasPrev={pipelinePage > 0}
hasNext={moreLoaded || pipelinesHasNext}
loadingNext={pipelinesFetchingNext}
onPrev={() => setPipelinePage((current) => Math.max(0, current - 1))}
onNext={nextPipelinePage}
/>
)}
</>
)}
Expand Down