From 4f2fb846dd448f360a18d2f19d562bd57f072585 Mon Sep 17 00:00:00 2001 From: erangel1 Date: Mon, 11 May 2026 20:49:48 +0200 Subject: [PATCH] pipeline dag visualization + Dashboard command center upgrade + command palette wiring. fixed repo pipeline page. --- AGENTS.md | 30 +- CHANGELOG.md | 121 ++++- README.md | 79 ++-- frontend/src/App.tsx | 4 + frontend/src/api/queries/dashboard.ts | 16 + frontend/src/api/queries/pipelines.ts | 179 +++++++- .../src/components/ci/PipelineWaterfall.tsx | 255 ++++++----- frontend/src/pages/DashboardPage.tsx | 102 ++++- frontend/src/pages/PipelineRunPage.tsx | 416 ++++++++++++++++++ frontend/src/pages/PipelinesPage.tsx | 255 ++++++++++- frontend/src/pages/RepoPipelinesPage.tsx | 239 ++++++++++ frontend/src/types/api.ts | 65 ++- internal/api/handlers/dashboard.go | 46 ++ internal/api/handlers/pipelines.go | 54 +++ internal/api/router.go | 1 + 15 files changed, 1659 insertions(+), 203 deletions(-) create mode 100644 frontend/src/pages/PipelineRunPage.tsx create mode 100644 frontend/src/pages/RepoPipelinesPage.tsx diff --git a/AGENTS.md b/AGENTS.md index c17f62b..f59d8b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,12 +55,32 @@ Understand the phases before adding code — don't build Phase 3 infrastructure | Phase | Scope | Status | |-------|-------|--------| -| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks model, LFS, design system, 20-page SPA | **Complete** | -| 2 | CI/CD orchestrator, runner manager, pipeline DAG visualization, artifact registry | **Active — `internal/domain/ci/` is the stub** | -| 3 | GitOps controller, environments, drift detection, federation handlers, observability, audit log | Planned | -| 4 | Command palette, AI diagnostics, signed artifacts, package registry | Planned | +| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system, 20-page SPA | **Complete** | +| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** | +| 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** | +| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Active** | +| 3A | Environment model + deployment tracking | Planned | +| 3B | Unified operational timeline | Planned | +| 3C | Secret management hierarchy | Planned | +| 3D | GitOps controller + drift detection | Planned | +| 3E | Observability (Prometheus, health sparklines) | Planned | +| 3F | Federation handlers (ActivityPub inbox/outbox) | Planned | +| 4 | AI diagnostics, signed artifacts, OCI registry, secret/dep scanning | Planned | -Do not implement Phase 3+ features without explicit discussion. The `domain/federation/` and `domain/ci/` directories are intentional stubs. +Do not implement Phase 3+ features without explicit discussion. The `domain/federation/` directory is an intentional stub — the data model exists but no HTTP handlers should be wired until Phase 3F. + +### Phase 2C — What's Left to Build + +All backend APIs for CI are complete. Phase 2C is entirely frontend work: + +1. **`types/api.ts`** — `Pipeline` type uses stale fields (`ref`, `status`). Must be updated to match backend (`name`, `filePath`). Add `PipelineRun`, `PipelineJob`, `PipelineStep`, `PipelineStepLog` types. +2. **`queries/pipelines.ts`** — Needs `useRuns`, `useRunDetail`, `useJobLogs`, cancel/retry mutations aligned with correct types. +3. **`GET /api/v1/pipelines/runs`** — A new backend endpoint returning recent runs across all repos owned by the current user (needed by the global `/pipelines` page and dashboard widget). +4. **`PipelinesPage`** — Currently an empty placeholder. Replace with real cross-repo runs list. +5. **`PipelineRunPage`** — New page at `/repos/:owner/:repo/runs/:runId`. Shows run header + DAG + step log viewer. +6. **`PipelineWaterfall`** — Currently uses mock data. Rewrite to accept real `PipelineJob[]` with `needs` dependency graph. +7. **Dashboard CI widget** — Replace hardcoded "Pipeline integration coming soon." with live recent runs. +8. **Command palette** — Add pipeline runs to search results. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index a843a10..fc6b480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,28 +9,108 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Planned — Phase 2 (CI/CD) -- CI orchestrator with DAG pipeline execution -- Runner manager (Docker, Kubernetes, Firecracker backends) -- Pipeline DAG visualization (visual dependency graph with live execution state) -- Build artifact storage and retention policies -- Forgejo Actions gRPC integration -- Flaky test detection -- Pipeline log viewer (collapsible, filterable, syntax-highlighted) -- Matrix builds and reusable workflow templates -- Concurrency controls and pipeline cancellation +### In Progress — Phase 2C (CI Legibility) +- Pipeline DAG visualization (PipelineRunPage with real job/step graph) +- Dashboard CI command center upgrade (replace placeholder with live recent runs) +- Command palette wiring (pipeline runs in search, Pipelines quick-nav) +- Global cross-repo pipeline runs feed (`/pipelines` page) +- Per-step log viewer (collapsible, streamed from backend) ### Planned — Phase 3 (GitOps + Observability + Federation) - GitOps controller with reconciliation loops -- Environment management and topology visualization +- Environment model + deployment tracking +- Unified operational timeline (commits + deployments + CI failures merged) - Drift detection and sync status - Deployment promotion workflows (dev → staging → production) - Rollback visualization and one-click rollbacks - Canary and blue/green deployment support -- Unified operational timeline (commits + deployments + incidents + CI failures merged) - ActivityPub / ForgeFed federation handlers (inbox, outbox, cross-instance PRs) -- Audit log (all administrative and git-over-HTTP actions) -- Secret scanning and dependency vulnerability scanning +- Secret management hierarchy (Global → Org → Repo → Env) +- Observability (Prometheus endpoint, health sparklines) + +### Planned — Phase 4 +- AI diagnostics (pipeline failure root-cause analysis) +- Signed artifacts (Sigstore/Cosign) +- OCI package registry +- Secret and dependency vulnerability scanning + +--- + +## [0.3.0] — 2026-05-11 + +Phase 2B complete. Full CI/CD execution backend operational. + +### Added — CI Orchestrator (`internal/domain/ci/`) +- DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`, + parses `.forgebucket/workflows/*.yml`, creates `PipelineRun`/`PipelineJob`/`PipelineStep` + records, advances DAG on `job.completed`/`job.failed`, recovers stale runs on startup +- Docker executor (`executor.go`): runs steps in isolated containers (`docker run --rm`), + streams logs to DB and NATS via `pipeline.log` subject, handles `git archive` workspace extraction +- Runner manager (`runner_manager.go`): semaphore-limited concurrent job dispatch (default 4), + subscribes to `job.queued`, calls executor when Docker is available +- DAG engine (`dag.go`): full topological sort (`TopoSort`) and `ReadyJobs` for dependency resolution +- Workflow parser (`parser.go`): reads `.forgebucket/workflows/*.yml` from git ref, + `MatchesPushTrigger` with glob pattern support +- CI types (`types.go`): `WorkflowFile`, `WorkflowJob`, `WorkflowStep`, YAML `StringOrSlice` unmarshaler + +### Added — CI API Handlers +- `GET /api/v1/repos/:owner/:repo/pipelines` — list pipeline definitions +- `GET /api/v1/repos/:owner/:repo/runs` — list pipeline runs (most recent first, limit 30) +- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with full job + step tree +- `POST /api/v1/repos/:owner/:repo/runs/:runID/cancel` — cancel queued or running run +- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry` — re-queue failed/cancelled job +- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step-level log chunks +- `GET /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — list artifacts for a run +- `POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — upload artifact (multipart, 512 MB max) +- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — artifact download with path traversal guard +- `GET /api/v1/admin/runners` — list registered runners (admin-only) +- `POST /api/v1/admin/runners/register` — register a new runner with bcrypt token hashing (admin-only) + +### Added — Database Models (migration `009_ci`) +- `Pipeline` — workflow definition record (name, filePath, repoId) +- `PipelineRun` — execution record (triggerRef, triggerSha, triggeredBy, status, startedAt, finishedAt) +- `PipelineJob` — single DAG node (name, image, needs JSON, status, timing) +- `PipelineStep` — single command within a job (seq, runCmd, usesAction, exitCode, timing) +- `PipelineStepLog` — append-only log chunk storage (stepId, chunkIndex, content) +- `Runner` — registered execution backend (name, labels, status, tokenHash, lastSeenAt) +- `Artifact` — build artifact (runId, repoId, name, storagePath, size, contentType) + +--- + +## [0.2.0] — 2026-05-11 + +Phase 2A complete. Real-time event infrastructure and audit log operational. + +### Added — NATS Event Bus (`internal/events/`) +- `EventBus` interface: `Publish`, `Subscribe`, `Close` +- `NATSBus`: NATS-backed implementation with auto-reconnect, max-reconnect disabled +- `NoOpBus`: silent fallback when `NATS_URL` is not configured (app fully functional without NATS) +- `New(url)` factory: returns `NATSBus` if URL is set, `NoOpBus` otherwise +- Event subjects defined in `subjects.go`: + - `repo.*` (created, deleted, pushed) + - `push.received` + - `pr.*` (opened, merged, closed) + - `issue.*` (opened, closed) + - `pipeline.*` (queued, started, succeeded, failed, cancelled) + - `job.*` (queued, started, completed, failed), `pipeline.log` + - `deployment.*`, `environment.*` (Phase 3 stubs) + - `audit.event` + +### Added — WebSocket Hub (`internal/api/handlers/ws.go`) +- `GET /ws` — upgrades HTTP to WebSocket (nhooyr.io/websocket) +- Subscribes to all NATS subjects on connect, fans events to the client as JSON +- Optional session auth (`auth.Optional` middleware) — works for guests too +- Phase 2B note: per-user event filtering is a planned upgrade + +### Added — Audit Log +- `AuditLog` model (migration `008_audit_log`): actor, method, path, statusCode, requestBody, ipAddr, timestamp +- `AuditLog` middleware: records every authenticated request to the DB and publishes `audit.event` +- `GET /api/v1/audit` — paginated audit log query (admin-only, filterable by actor/method/time range) + +### Fixed — Local development environment +- `DATABASE_URL` was using Docker-internal hostname `postgres`; corrected to `localhost` for `make dev` +- Added `NATS_URL=nats://localhost:4222` to `.env` (was missing; CI orchestrator requires it) +- `REPO_ROOT` corrected to `/tmp/forgebucket/repos` (Docker path `/var/lib/forgebucket/repos` requires sudo on macOS) --- @@ -70,7 +150,9 @@ Initial development milestone. Core Git hosting, collaboration, and frontend SPA ### Added — Frontend SPA - React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed` -- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo, RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred, PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings +- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo, + RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred, + PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings - AppShell layout wrapper for all authenticated pages - Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar - Mobile-first responsive design (375px → 1440px) @@ -90,13 +172,16 @@ Initial development milestone. Core Git hosting, collaboration, and frontend SPA - System font stack (Segoe UI, Roboto, sans-serif) ### Added — Infrastructure -- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys, access tokens, deploy keys, workflows, and LFS settings +- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys, + access tokens, deploy keys, workflows, and LFS settings - ActivityPub actor data model (FederationActor with inbox/outbox URLs and RSA key pairs) — data layer only -- Docker Compose setup for local PostgreSQL +- Docker Compose setup for local PostgreSQL + NATS - Makefile targets: dev, build, migrate, test, lint, docker-up - WebSockets foundation for live logs and notifications --- -[Unreleased]: https://github.com/forgeo/forgebucket/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/forgeo/forgebucket/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/forgeo/forgebucket/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/forgeo/forgebucket/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/forgeo/forgebucket/releases/tag/v0.1.0 diff --git a/README.md b/README.md index e5e9dfd..ce94fba 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where other Git platforms show you a list of files, ForgeBucket surfaces deployments, pipeline health, environment drift, and operational context directly alongside your code. Repositories are runtime systems. The dashboard is a command center. -**Status:** Early development. Core Git hosting, collaboration, and auth are functional. CI/CD and GitOps integrations are next. +**Status:** Phase 2C in progress. CI/CD execution backend is fully operational. Pipeline visualization and dashboard integration are being wired up now. --- @@ -32,6 +32,7 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth | OIDC / OAuth2 (optional) | Done | | Access tokens (scoped, expiring) | Done | | Deploy keys | Done | +| Audit log | Done | ### Git Hosting | Feature | Status | @@ -52,44 +53,50 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth | Side-by-side + unified diff viewer | Done | | Reviewer assignment | Done | | Merge strategies (merge/squash/rebase) | Done | -| Webhooks | Done (model + routes) | +| Webhooks | Done | | Repository RBAC (read/write/admin) | Done | ### CI/CD | Feature | Status | |---------|--------| -| Pipeline DAG visualization | In progress | -| CI orchestrator | Planned (Phase 2) | -| Runner manager | Planned (Phase 2) | -| Artifact registry | Planned (Phase 2) | -| Forgejo Actions integration (gRPC) | Planned (Phase 2) | -| Flaky test detection | Planned (Phase 2) | +| CI orchestrator (DAG pipeline execution) | Done (Phase 2B) | +| Runner manager (Docker backend) | Done (Phase 2B) | +| Build artifact storage | Done (Phase 2B) | +| Pipeline cancellation + job retry | Done (Phase 2B) | +| NATS event bus + WebSocket live push | Done (Phase 2A) | +| Pipeline DAG visualization (frontend) | **In progress (Phase 2C)** | +| Dashboard CI command center | **In progress (Phase 2C)** | +| Pipeline log viewer (per-step, collapsible) | **In progress (Phase 2C)** | +| Kubernetes / Firecracker runner backends | Planned (Phase 2D) | +| Forgejo Actions gRPC integration | Planned | +| Matrix builds + reusable workflow templates | Planned | +| Flaky test detection | Planned | ### GitOps + Environments | Feature | Status | |---------|--------| -| GitOps controller | Planned (Phase 3) | -| Environment management | Planned (Phase 3) | -| Drift detection | Planned (Phase 3) | -| Deployment promotion workflows | Planned (Phase 3) | -| Rollback visualization | Planned (Phase 3) | -| Canary / blue-green support | Planned (Phase 3) | +| Environment model + deployment tracking | Planned (Phase 3A) | +| Unified operational timeline | Planned (Phase 3B) | +| Secret management hierarchy | Planned (Phase 3C) | +| GitOps controller + drift detection | Planned (Phase 3D) | +| Deployment promotion workflows | Planned (Phase 3D) | +| Rollback visualization | Planned (Phase 3D) | +| Canary / blue-green support | Planned (Phase 3D) | ### Observability + Security | Feature | Status | |---------|--------| -| Unified operational timeline | Planned (Phase 3) | -| Secret scanning | Planned (Phase 3) | -| Dependency scanning | Planned (Phase 3) | +| Prometheus endpoint + health sparklines | Planned (Phase 3E) | +| Secret scanning | Planned (Phase 4) | +| Dependency scanning | Planned (Phase 4) | | Signed artifacts (Sigstore/Cosign) | Planned (Phase 4) | -| Audit log | Planned (Phase 3) | ### Federation | Feature | Status | |---------|--------| | ActivityPub actor model | Done (data layer) | -| Federation handlers / inbox / outbox | Planned (Phase 3) | -| Cross-instance pull requests | Planned (Phase 3) | +| Federation handlers / inbox / outbox | Planned (Phase 3F) | +| Cross-instance pull requests | Planned (Phase 3F) | --- @@ -101,7 +108,7 @@ git clone https://github.com/forgeo/forgebucket.git cd forgebucket cp .env.example .env # fill in SESSION_SECRET and CSRF_SECRET -# 2. Start PostgreSQL +# 2. Start PostgreSQL + NATS make docker-up # 3. Run DB migrations @@ -113,6 +120,8 @@ make dev The Go API runs at `http://localhost:8080`. The Vite dev server runs at `http://localhost:5173` and proxies API requests. +> **Local dev note:** `DATABASE_URL` must use `localhost` (not `postgres`) and `NATS_URL` must be set to `nats://localhost:4222`. The `.env` file ships with correct defaults for local development. See `.env.example` for all variables. + --- ## Architecture @@ -124,8 +133,9 @@ ForgeBucket ├── Repository Service (git HTTP, branches, LFS — internal/domain/git/) ├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/) ├── Issue Service (issues, labels — internal/api/handlers/) -├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← stub -├── CI Orchestrator (pipeline scheduling — internal/domain/ci/) ← stub +├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/) ← Phase 2B done +├── Event Bus (NATS core, NoOp fallback — internal/events/) ← Phase 2A done +├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← Phase 3F stub ├── Secret Manager (env-based, scoped tokens — internal/config/) ├── Database (PostgreSQL + XORM — internal/models/) └── Web Frontend (React 18 + TypeScript, embedded via //go:embed — web/) @@ -133,7 +143,7 @@ ForgeBucket **Middleware chain (every request):** ``` -Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler +Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → AuditLog → Handler ``` --- @@ -145,13 +155,15 @@ Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → H | Language | Go 1.21+ | | Router | Chi | | ORM / Migrations | XORM + PostgreSQL | +| Event bus | NATS (core; JetStream planned for Phase 2B durability) | +| Real-time | WebSockets (nhooyr.io/websocket) | +| CI execution | Docker (`docker run --rm`) | | Frontend framework | React 18 + TypeScript | | Build tool | Vite | | Styling | Tailwind CSS v4 | | Code editing | CodeMirror | -| Real-time | WebSockets | | Container | Docker Compose (dev) | -| Federation | ActivityPub / ForgeFed | +| Federation | ActivityPub / ForgeFed (data layer only) | --- @@ -174,17 +186,18 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a | Variable | Required | Description | |----------|----------|-------------| -| `DATABASE_URL` | Yes | PostgreSQL connection string | +| `DATABASE_URL` | Yes | PostgreSQL connection string — use `localhost` for local dev | | `SESSION_SECRET` | Yes | Session signing key, ≥ 32 chars (`openssl rand -hex 32`) | | `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) | | `PORT` | No | HTTP port, default `8080` | | `REPO_ROOT` | Yes | Absolute path for bare git repository storage | +| `NATS_URL` | No | NATS connection URL (e.g. `nats://localhost:4222`). If unset, CI runs in no-op mode | | `INSTANCE_URL` | Yes | Public URL of this instance (no trailing slash) | | `INSTANCE_NAME` | No | Display name, default `ForgeBucket` | | `OIDC_ISSUER` | No | OIDC provider URL | | `OIDC_CLIENT_ID` | No | OIDC client ID | | `OIDC_CLIENT_SECRET` | No | OIDC client secret | -| `DEBUG` | No | Disables Secure cookies, enables verbose logging | +| `DEBUG` | No | Disables Secure cookies, enables verbose logging, proxies frontend to Vite | --- @@ -197,7 +210,7 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a | `make migrate` | Sync XORM schemas to PostgreSQL | | `make test` | Run Go tests + Vitest | | `make lint` | `go vet` + ESLint | -| `make docker-up` | Start PostgreSQL via Docker Compose | +| `make docker-up` | Start PostgreSQL + NATS via Docker Compose | --- @@ -206,9 +219,11 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a | Phase | Focus | Status | |-------|-------|--------| | Phase 1 | Core Git hosting, auth, PRs, issues, RBAC, design system | Done | -| Phase 2 | CI/CD orchestrator, runner manager, pipeline visualization, artifact registry | In progress | -| Phase 3 | GitOps controller, environments, observability, federation handlers, audit log | Planned | -| Phase 4 | Command palette, AI diagnostics, signed artifacts, package registry | Planned | +| Phase 2A | NATS event bus, WebSocket hub, audit log | Done | +| Phase 2B | CI orchestrator, runner manager, Docker backend, artifact registry | Done | +| Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | **In progress** | +| Phase 3A–F | GitOps, environments, timeline, secrets, drift detection, federation | Planned | +| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned | --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c16632b..d26526f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,8 @@ const BranchesPage = lazy(() => import('./pages/BranchesPage')) const StarredPage = lazy(() => import('./pages/StarredPage')) const PRsPage = lazy(() => import('./pages/PRsPage')) const PipelinesPage = lazy(() => import('./pages/PipelinesPage')) +const PipelineRunPage = lazy(() => import('./pages/PipelineRunPage')) +const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage')) const ProfilePage = lazy(() => import('./pages/ProfilePage')) const ExplorePage = lazy(() => import('./pages/ExplorePage')) const SettingsPage = lazy(() => import('./pages/SettingsPage')) @@ -82,6 +84,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/queries/dashboard.ts b/frontend/src/api/queries/dashboard.ts index d633e1d..84e3aa6 100644 --- a/frontend/src/api/queries/dashboard.ts +++ b/frontend/src/api/queries/dashboard.ts @@ -48,18 +48,34 @@ const dashRepoSchema = z.object({ openIssueCount: z.number(), }) +const dashRunSchema = z.object({ + id: z.number(), + repoId: z.number(), + repoName: z.string(), + ownerName: z.string(), + triggerRef: z.string(), + triggerSha: z.string(), + triggeredBy: z.string(), + status: z.string(), + startedAt: z.string().nullable(), + finishedAt: z.string().nullable(), + createdAt: z.string(), +}) + const dashboardSchema = z.object({ stats: statsSchema, reviewQueue: z.array(dashPRSchema), myOpenPRs: z.array(dashPRSchema), myOpenIssues: z.array(dashIssueSchema), repos: z.array(dashRepoSchema), + recentRuns: z.array(dashRunSchema).optional().default([]), }) export type DashboardData = z.infer export type DashPR = z.infer export type DashIssue = z.infer export type DashRepo = z.infer +export type DashRun = z.infer export function useDashboard() { return useQuery({ diff --git a/frontend/src/api/queries/pipelines.ts b/frontend/src/api/queries/pipelines.ts index d4da01c..85f4e70 100644 --- a/frontend/src/api/queries/pipelines.ts +++ b/frontend/src/api/queries/pipelines.ts @@ -1,37 +1,190 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { z } from 'zod' import { api } from '../client' -import type { Pipeline } from '../../types/api' +import type { Pipeline, PipelineRun, RunDetail, StepLogs } from '../../types/api' + +// ── Zod schemas ─────────────────────────────────────────────────────────────── + +const runStatusSchema = z.enum(['queued', 'running', 'succeeded', 'failed', 'cancelled']) const pipelineSchema = z.object({ id: z.number(), repoId: z.number(), - ref: z.string(), - status: z.enum(['pending', 'running', 'success', 'failure', 'cancelled']), + name: z.string(), + filePath: z.string(), createdAt: z.string(), updatedAt: z.string(), }) -const pipelinesSchema = z.array(pipelineSchema) +const runSchema = z.object({ + id: z.number(), + pipelineId: z.number(), + repoId: z.number(), + triggerRef: z.string(), + triggerSha: z.string(), + triggeredBy: z.string(), + status: runStatusSchema, + startedAt: z.string().nullable(), + finishedAt: z.string().nullable(), + createdAt: z.string(), +}) +const stepSchema = z.object({ + id: z.number(), + jobId: z.number(), + seq: z.number(), + name: z.string(), + runCmd: z.string(), + usesAction: z.string(), + status: runStatusSchema, + exitCode: z.number(), + startedAt: z.string().nullable(), + finishedAt: z.string().nullable(), +}) + +const jobSchema = z.object({ + id: z.number(), + runId: z.number(), + name: z.string(), + image: z.string(), + needs: z.string(), + status: runStatusSchema, + startedAt: z.string().nullable(), + finishedAt: z.string().nullable(), + createdAt: z.string(), + steps: z.array(stepSchema), +}) + +const runDetailSchema = runSchema.extend({ + jobs: z.array(jobSchema), +}) + +const stepLogSchema = z.object({ + id: z.number(), + stepId: z.number(), + chunkIndex: z.number(), + content: z.string(), + createdAt: z.string(), +}) + +const stepLogsSchema = z.array( + stepSchema.extend({ logs: z.array(stepLogSchema) }), +) + +// ── Queries ─────────────────────────────────────────────────────────────────── + +/** Pipeline definitions for a repo. */ export function usePipelines(owner: string, repo: string) { return useQuery({ queryKey: ['repos', owner, repo, 'pipelines'], queryFn: () => - api.get(`/api/v1/repos/${owner}/${repo}/pipelines`, pipelinesSchema), + api.get(`/api/v1/repos/${owner}/${repo}/pipelines`, z.array(pipelineSchema)), enabled: Boolean(owner && repo), - refetchInterval: 5000, // poll while pipelines may be running }) } -export function usePipeline(owner: string, repo: string, runId: number) { +/** Pipeline runs for a repo, newest first. */ +export function useRuns(owner: string, repo: string, limit = 30) { return useQuery({ - queryKey: ['repos', owner, repo, 'pipelines', runId], + queryKey: ['repos', owner, repo, 'runs', limit], queryFn: () => - api.get( - `/api/v1/repos/${owner}/${repo}/pipelines/${runId}`, - pipelineSchema, + api.get( + `/api/v1/repos/${owner}/${repo}/runs?limit=${limit}`, + z.array(runSchema), ), - enabled: Boolean(owner && repo && runId), + enabled: Boolean(owner && repo), + refetchInterval: 8_000, // poll while runs may be active }) } + +/** Run detail: run + jobs (each with steps). */ +export function useRunDetail(owner: string, repo: string, runId: number) { + return useQuery({ + queryKey: ['repos', owner, repo, 'runs', runId], + queryFn: () => + api.get( + `/api/v1/repos/${owner}/${repo}/runs/${runId}`, + runDetailSchema, + ), + enabled: Boolean(owner && repo && runId), + refetchInterval: (query) => { + const status = query.state.data?.status + return status === 'running' || status === 'queued' ? 3_000 : false + }, + }) +} + +/** Step-level log chunks for a job. */ +export function useJobLogs(owner: string, repo: string, runId: number, jobId: number) { + return useQuery({ + queryKey: ['repos', owner, repo, 'runs', runId, 'jobs', jobId, 'logs'], + queryFn: () => + api.get( + `/api/v1/repos/${owner}/${repo}/runs/${runId}/jobs/${jobId}/logs`, + stepLogsSchema, + ), + enabled: Boolean(owner && repo && runId && jobId), + refetchInterval: (query) => { + // Keep polling only while the job may still be running + const hasRunning = query.state.data?.some(s => s.status === 'running') + return hasRunning ? 2_000 : false + }, + }) +} + +/** Recent pipeline runs across all repos owned by the current user. */ +export function useRecentRuns(limit = 20) { + return useQuery({ + queryKey: ['pipelines', 'runs', limit], + queryFn: () => + api.get(`/api/v1/pipelines/runs?limit=${limit}`, recentRunSchema), + refetchInterval: 10_000, + }) +} + +// ── Mutations ───────────────────────────────────────────────────────────────── + +export function useCancelRun(owner: string, repo: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (runId: number) => + api.post(`/api/v1/repos/${owner}/${repo}/runs/${runId}/cancel`, z.unknown(), undefined), + onSuccess: (_data, runId) => { + qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs'] }) + qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs', runId] }) + qc.invalidateQueries({ queryKey: ['pipelines', 'runs'] }) + }, + }) +} + +export function useRetryJob(owner: string, repo: string, runId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (jobId: number) => + api.post( + `/api/v1/repos/${owner}/${repo}/runs/${runId}/jobs/${jobId}/retry`, + z.unknown(), + undefined, + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs', runId] }) + qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs'] }) + qc.invalidateQueries({ queryKey: ['pipelines', 'runs'] }) + }, + }) +} + +// ── Cross-repo recent run type ───────────────────────────────────────────────── +// Returned by GET /api/v1/pipelines/runs — extends PipelineRun with repo context. + +export interface RecentRun extends PipelineRun { + repoName: string + ownerName: string +} + +const recentRunSchema = z.array( + runSchema.extend({ + repoName: z.string(), + ownerName: z.string(), + }), +) diff --git a/frontend/src/components/ci/PipelineWaterfall.tsx b/frontend/src/components/ci/PipelineWaterfall.tsx index a463124..3ff32f4 100644 --- a/frontend/src/components/ci/PipelineWaterfall.tsx +++ b/frontend/src/components/ci/PipelineWaterfall.tsx @@ -1,135 +1,90 @@ import { cn } from '../../lib/utils' -import type { Pipeline } from '../../types/api' +import type { PipelineJob, RunStatus } from '../../types/api' -interface Stage { - name: string - status: Pipeline['status'] - duration?: string -} +// ── Status maps ─────────────────────────────────────────────────────────────── -interface PipelineWaterfallProps { - pipeline: Pipeline - stages?: Stage[] -} - -const STATUS_COLOR: Record = { - pending: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', +const STATUS_COLOR: Record = { + queued: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', running: 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)] text-[var(--c-brand)]', - success: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]', - failure: 'bg-[var(--c-danger-tint)] border-[#FF8F73] text-[var(--c-danger-dark)]', + succeeded: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]', + failed: 'bg-[var(--c-danger-tint)] border-[#FF8F73] text-[var(--c-danger-dark)]', cancelled: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', } -const STATUS_DOT: Record = { - pending: 'bg-[var(--c-subtle)]', +const STATUS_DOT: Record = { + queued: 'bg-[var(--c-subtle)]', running: 'bg-[var(--c-brand)] animate-pulse', - success: 'bg-[var(--c-success)]', - failure: 'bg-[var(--c-danger)]', + succeeded: 'bg-[var(--c-success)]', + failed: 'bg-[var(--c-danger)]', cancelled: 'bg-[var(--c-subtle)]', } -const STATUS_LABEL: Record = { - pending: 'Pending', +const STATUS_LABEL: Record = { + queued: 'Queued', running: 'Running', - success: 'Passed', - failure: 'Failed', + succeeded: 'Passed', + failed: 'Failed', cancelled: 'Cancelled', } -// Default stage breakdown when no stage data is provided -function defaultStages(status: Pipeline['status']): Stage[] { - const stages: Array<{ name: string; order: number }> = [ - { name: 'Clone', order: 0 }, - { name: 'Build', order: 1 }, - { name: 'Test', order: 2 }, - { name: 'Deploy', order: 3 }, - ] - return stages.map((s, i) => ({ - name: s.name, - status: deriveStageStatus(status, i, stages.length), - })) +// ── Props ───────────────────────────────────────────────────────────────────── + +interface PipelineWaterfallProps { + /** Jobs from a PipelineRun. Each job has a `needs` JSON array of dependency names. */ + jobs: PipelineJob[] + /** Overall run status — used for the header badge. */ + runStatus: RunStatus + runId: number + /** Called when user clicks a job node. */ + onSelectJob?: (jobId: number) => void + selectedJobId?: number | null } -function deriveStageStatus(pipelineStatus: Pipeline['status'], idx: number, total: number): Pipeline['status'] { - if (pipelineStatus === 'success') return 'success' - if (pipelineStatus === 'pending') return 'pending' - if (pipelineStatus === 'cancelled') return idx === 0 ? 'cancelled' : 'pending' - if (pipelineStatus === 'failure') { - const failAt = Math.floor(total * 0.6) - if (idx < failAt) return 'success' - if (idx === failAt) return 'failure' - return 'pending' +// ── DAG column builder ──────────────────────────────────────────────────────── + +function topoColumns(jobs: PipelineJob[]): PipelineJob[][] { + const nameToJob = new Map(jobs.map(j => [j.name, j])) + const depth = new Map() + + function getDepth(name: string, visited = new Set()): number { + if (depth.has(name)) return depth.get(name)! + if (visited.has(name)) return 0 + visited.add(name) + const job = nameToJob.get(name) + if (!job) return 0 + let needs: string[] = [] + try { needs = JSON.parse(job.needs || '[]') } catch { needs = [] } + const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited)))) + depth.set(name, d) + return d } - // running - const runAt = Math.floor(total * 0.4) - if (idx < runAt) return 'success' - if (idx === runAt) return 'running' - return 'pending' + + jobs.forEach(j => getDepth(j.name)) + const maxDepth = Math.max(...Array.from(depth.values()), 0) + const cols: PipelineJob[][] = Array.from({ length: maxDepth + 1 }, () => []) + jobs.forEach(j => cols[depth.get(j.name) ?? 0].push(j)) + return cols.filter(c => c.length > 0) } -export function PipelineWaterfall({ pipeline, stages }: PipelineWaterfallProps) { - const resolvedStages = stages ?? defaultStages(pipeline.status) - - return ( -
- {/* Pipeline header */} -
-
- - - Pipeline #{pipeline.id} - - - {STATUS_LABEL[pipeline.status]} - -
- {pipeline.ref} -
- - {/* Waterfall stages */} -
- {resolvedStages.map((stage, i) => ( -
- {/* Stage box */} -
- - {stage.name} - {stage.duration && ( - {stage.duration} - )} -
- - {/* Connector arrow (not after last) */} - {i < resolvedStages.length - 1 && ( -
-
- - - -
- )} -
- ))} -
-
- ) +function duration(start: string | null, end: string | null): string { + if (!start) return '' + const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime() + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${s % 60}s` } -function StatusIcon({ status }: { status: Pipeline['status'] }) { - if (status === 'success') { +// ── Status icon ─────────────────────────────────────────────────────────────── + +function StatusIcon({ status }: { status: RunStatus }) { + if (status === 'succeeded') { return ( ) } - if (status === 'failure') { + if (status === 'failed') { return ( @@ -143,9 +98,101 @@ function StatusIcon({ status }: { status: Pipeline['status'] }) { ) } + if (status === 'cancelled') { + return ( + + + + ) + } + // queued return ( ) } + +// ── Component ───────────────────────────────────────────────────────────────── + +export function PipelineWaterfall({ + jobs, + runStatus, + runId, + onSelectJob, + selectedJobId, +}: PipelineWaterfallProps) { + const columns = topoColumns(jobs) + + return ( +
+ {/* Header */} +
+
+ + + Run #{runId} + + + {STATUS_LABEL[runStatus]} + +
+ + {jobs.length} job{jobs.length !== 1 ? 's' : ''} + +
+ + {/* DAG waterfall */} + {columns.length === 0 ? ( +

No jobs.

+ ) : ( +
+ {columns.map((col, colIdx) => ( +
+ {/* Column */} +
+ {col.map(job => { + const status = (job.status as RunStatus) || 'queued' + const isSelected = selectedJobId === job.id + return ( + + ) + })} +
+ + {/* Connector (not after last column) */} + {colIdx < columns.length - 1 && ( +
+
+ + + +
+ )} +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 416454b..b8193cc 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -3,10 +3,11 @@ import { Link, useNavigate } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' import { useDashboard } from '../api/queries/dashboard' import { useRepos } from '../api/queries/repos' +import { useRecentRuns } from '../api/queries/pipelines' import { useRecentRepos } from '../hooks/useRecentRepos' import { Skeleton } from '../ui/Skeleton' import { RepoAvatar } from '../ui/RepoAvatar' -import type { DashPR, DashIssue, DashRepo } from '../api/queries/dashboard' +import type { DashPR, DashIssue, DashRepo, DashRun } from '../api/queries/dashboard' // ── Utilities ───────────────────────────────────────────────────────────────── @@ -31,7 +32,7 @@ function greeting(username?: string): string { // ── Command palette ──────────────────────────────────────────────────────────── interface CmdResult { - type: 'repo' | 'pr' | 'issue' + type: 'repo' | 'pr' | 'issue' | 'run' label: string sub: string href: string @@ -40,6 +41,7 @@ interface CmdResult { function CommandPalette() { const { data: dash } = useDashboard() const { data: repos = [] } = useRepos() + const { data: recentRuns = [] } = useRecentRuns(20) const [open, setOpen] = useState(false) const [q, setQ] = useState('') const inputRef = useRef(null) @@ -73,6 +75,19 @@ function CommandPalette() { .filter(i => i.title.toLowerCase().includes(q.toLowerCase())) .slice(0, 3) .map(i => ({ type: 'issue' as const, label: i.title, sub: `${i.ownerName}/${i.repoName} · #${i.number}`, href: `/repos/${i.ownerName}/${i.repoName}/issues` })), + ...recentRuns + .filter(r => + r.repoName.toLowerCase().includes(q.toLowerCase()) || + r.triggerRef.toLowerCase().includes(q.toLowerCase()) || + r.triggerSha.startsWith(q.toLowerCase()), + ) + .slice(0, 3) + .map(r => ({ + type: 'run' as const, + label: `${r.repoName} #${r.id}`, + sub: `${r.triggerRef.replace('refs/heads/', '')} · ${r.status} · ${r.triggerSha.slice(0, 7)}`, + href: `/repos/${r.ownerName}/${r.repoName}/runs/${r.id}`, + })), ] : [] @@ -96,6 +111,11 @@ function CommandPalette() { ) + if (t === 'run') return ( + + + + ) return ( @@ -110,7 +130,7 @@ function CommandPalette() { - Search repos, PRs, issues… + Search repos, PRs, issues, pipelines… K @@ -126,7 +146,7 @@ function CommandPalette() { setQ(e.target.value)} onKeyDown={onKey} - placeholder="Search repos, PRs, issues…" + placeholder="Search repos, PRs, issues, pipelines…" className="flex-1 bg-transparent text-sm text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none" /> esc
@@ -154,6 +174,7 @@ function CommandPalette() { {[ { label: 'My repos', href: '/repos' }, { label: 'Open PRs', href: '/pulls' }, + { label: 'Pipelines', href: '/pipelines' }, { label: 'Issues', href: '/issues' }, { label: 'Explore', href: '/explore' }, { label: 'Settings', href: '/settings' }, @@ -300,6 +321,47 @@ function RepoCard({ repo }: { repo: DashRepo }) { ) } +// ── CI run row ──────────────────────────────────────────────────────────────── + +const RUN_DOT: Record = { + queued: 'bg-[var(--c-subtle)]', + running: 'bg-[var(--c-brand)] animate-pulse', + succeeded: 'bg-[var(--c-success)]', + failed: 'bg-[var(--c-danger)]', + cancelled: 'bg-[var(--c-subtle)]', +} + +const RUN_LABEL: Record = { + queued: 'Queued', + running: 'Running', + succeeded: 'Passed', + failed: 'Failed', + cancelled: 'Cancelled', +} + +function CIRunRow({ run }: { run: DashRun }) { + const branch = run.triggerRef.replace('refs/heads/', '').replace('refs/tags/', '') + const sha = run.triggerSha.slice(0, 7) + return ( + + +
+
+ + {run.repoName} + + {branch} +
+

{sha}

+
+ + {RUN_LABEL[run.status] ?? run.status} + + + ) +} + // ── Empty state ─────────────────────────────────────────────────────────────── function Empty({ message, action }: { message: string; action?: React.ReactNode }) { @@ -502,7 +564,7 @@ export default function DashboardPage() { )} - {/* CI/CD — placeholder until pipelines are implemented */} + {/* CI / CD — live recent runs */}
} + action={ + All runs + } > - -
-
- - - -
-

Pipeline integration coming soon.

- View pipelines → -
-
+ {isLoading ? : ( + + {!dash?.recentRuns?.length ? ( + + Push to a repo with a .forgebucket/workflows/ file. +

+ } + /> + ) : ( + dash.recentRuns.map(run => ) + )} +
+ )}
{/* Quick actions */} diff --git a/frontend/src/pages/PipelineRunPage.tsx b/frontend/src/pages/PipelineRunPage.tsx new file mode 100644 index 0000000..d7579b5 --- /dev/null +++ b/frontend/src/pages/PipelineRunPage.tsx @@ -0,0 +1,416 @@ +import { useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines' +import { Skeleton } from '../ui/Skeleton' +import { cn } from '../lib/utils' +import type { PipelineJob, PipelineStep, RunStatus } from '../types/api' + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function timeAgo(iso: string | null): string { + if (!iso) return '—' + const diff = Date.now() - new Date(iso).getTime() + const min = Math.floor(diff / 60_000) + if (min < 1) return 'just now' + if (min < 60) return `${min}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + return `${Math.floor(hr / 24)}d ago` +} + +function duration(start: string | null, end: string | null): string { + if (!start) return '—' + const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime() + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + return `${m}m ${s % 60}s` +} + +function shortRef(ref: string): string { + return ref.replace('refs/heads/', '').replace('refs/tags/', '') +} + +function shortSHA(sha: string): string { + return sha.slice(0, 7) +} + +// ── Status helpers ───────────────────────────────────────────────────────────── + +const STATUS_DOT: Record = { + queued: 'bg-[var(--c-subtle)]', + running: 'bg-[var(--c-brand)] animate-pulse', + succeeded: 'bg-[var(--c-success)]', + failed: 'bg-[var(--c-danger)]', + cancelled: 'bg-[var(--c-subtle)]', +} + +const STATUS_BADGE: Record = { + queued: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', + running: 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)] text-[var(--c-brand)]', + succeeded: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]', + failed: 'bg-[var(--c-danger-tint)] border-[#FF8F73] text-[var(--c-danger-dark)]', + cancelled: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', +} + +const STATUS_LABEL: Record = { + queued: 'Queued', + running: 'Running', + succeeded: 'Passed', + failed: 'Failed', + cancelled: 'Cancelled', +} + +function StatusBadge({ status, size = 'md' }: { status: RunStatus; size?: 'sm' | 'md' }) { + return ( + + + {STATUS_LABEL[status]} + + ) +} + +// ── DAG visualization ───────────────────────────────────────────────────────── + +type JobWithSteps = PipelineJob & { steps: PipelineStep[] } + +interface DAGProps { + jobs: JobWithSteps[] + selectedJobId: number | null + onSelectJob: (id: number) => void +} + +function DAGView({ jobs, selectedJobId, onSelectJob }: DAGProps) { + // Build dependency columns using topological sort + const columns = topoColumns(jobs) + + if (jobs.length === 0) { + return ( +
+ No jobs in this run. +
+ ) + } + + return ( +
+
+ {columns.map((col, colIdx) => ( +
+ {/* Column of jobs */} +
+ {col.map(job => ( + + ))} +
+ + {/* Connector (not after last column) */} + {colIdx < columns.length - 1 && ( +
+
+ + + +
+ )} +
+ ))} +
+
+ ) +} + +function JobStatusIcon({ status }: { status: RunStatus }) { + if (status === 'succeeded') { + return + } + if (status === 'failed') { + return + } + if (status === 'running') { + return + } + if (status === 'cancelled') { + return + } + // queued + return +} + +/** Arrange jobs into columns by dependency depth (0 = no deps). */ +function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] { + const nameToJob = new Map(jobs.map(j => [j.name, j])) + const depth = new Map() + + function getDepth(name: string, visited = new Set()): number { + if (depth.has(name)) return depth.get(name)! + if (visited.has(name)) return 0 + visited.add(name) + const job = nameToJob.get(name) + if (!job) return 0 + let needs: string[] = [] + try { needs = JSON.parse(job.needs || '[]') } catch { needs = [] } + const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited)))) + depth.set(name, d) + return d + } + + jobs.forEach(j => getDepth(j.name)) + const maxDepth = Math.max(...Array.from(depth.values()), 0) + const cols: JobWithSteps[][] = Array.from({ length: maxDepth + 1 }, () => []) + jobs.forEach(j => cols[depth.get(j.name) ?? 0].push(j)) + return cols.filter(c => c.length > 0) +} + +// ── Log viewer ───────────────────────────────────────────────────────────────── + +function LogViewer({ owner, repo, runId, jobId }: { + owner: string; repo: string; runId: number; jobId: number +}) { + const { data, isLoading } = useJobLogs(owner, repo, runId, jobId) + const [expandedSteps, setExpandedSteps] = useState>(new Set([0])) + + if (isLoading) { + return ( +
+ {[1, 2, 3].map(i => )} +
+ ) + } + if (!data || data.length === 0) { + return

No steps recorded for this job.

+ } + + function toggle(seq: number) { + setExpandedSteps(prev => { + const next = new Set(prev) + if (next.has(seq)) next.delete(seq) + else next.add(seq) + return next + }) + } + + return ( +
+ {data.map(step => { + const open = expandedSteps.has(step.seq) + const logText = step.logs.map(l => l.content).join('') + return ( +
+ + {open && ( +
+ {logText ? ( +
+                    {logText}
+                  
+ ) : ( +

No output captured.

+ )} +
+ )} +
+ ) + })} +
+ ) +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function PipelineRunPage() { + const { owner = '', repo = '', runId = '' } = useParams() + const runIdNum = parseInt(runId, 10) + + const { data: run, isLoading, error } = useRunDetail(owner, repo, runIdNum) + const cancelRun = useCancelRun(owner, repo) + const retryJob = useRetryJob(owner, repo, runIdNum) + + const [selectedJobId, setSelectedJobId] = useState(null) + + // Auto-select first job once data loads + const jobs = run?.jobs ?? [] + const effectiveJobId = selectedJobId ?? jobs[0]?.id ?? null + const selectedJob = jobs.find(j => j.id === effectiveJobId) ?? null + + if (error) { + return ( +
+

Run not found.

+ + ← Back to repository + +
+ ) + } + + return ( +
+ + {/* Breadcrumb */} + + + {/* Run header */} + {isLoading ? ( +
+ + +
+ ) : run ? ( +
+
+
+
+

+ Run #{run.id} +

+ +
+
+ + + + + {shortRef(run.triggerRef)} + + {shortSHA(run.triggerSha)} + triggered by {run.triggeredBy} + {timeAgo(run.createdAt)} + {run.startedAt && ( + duration: {duration(run.startedAt, run.finishedAt)} + )} +
+
+ + {/* Actions */} +
+ {(run.status === 'running' || run.status === 'queued') && ( + + )} +
+
+
+ ) : null} + + {/* DAG + log viewer */} +
+ + {/* DAG section */} +
+

+ Jobs +

+
+ {isLoading ? ( +
+ {[1, 2, 3].map(i => )} +
+ ) : ( + setSelectedJobId(id)} + /> + )} +
+
+ + {/* Log viewer for selected job */} + {effectiveJobId !== null && ( +
+
+

+ {selectedJob ? `Logs — ${selectedJob.name}` : 'Logs'} +

+
+ {selectedJob && ( + + )} + {selectedJob && (selectedJob.status === 'failed' || selectedJob.status === 'cancelled') && ( + + )} +
+
+
+ +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/PipelinesPage.tsx b/frontend/src/pages/PipelinesPage.tsx index c46dda8..8a74b3d 100644 --- a/frontend/src/pages/PipelinesPage.tsx +++ b/frontend/src/pages/PipelinesPage.tsx @@ -1,19 +1,248 @@ -export default function PipelinesPage() { +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useRecentRuns } from '../api/queries/pipelines' +import { Skeleton } from '../ui/Skeleton' +import { cn } from '../lib/utils' +import type { RunStatus } from '../types/api' + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const min = Math.floor(diff / 60_000) + if (min < 1) return 'just now' + if (min < 60) return `${min}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + return `${Math.floor(hr / 24)}d ago` +} + +function duration(start: string | null, end: string | null): string { + if (!start) return '—' + const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime() + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${s % 60}s` +} + +function shortRef(ref: string): string { + return ref.replace('refs/heads/', '').replace('refs/tags/', '') +} + +function shortSHA(sha: string): string { + return sha.slice(0, 7) +} + +// ── Status helpers ───────────────────────────────────────────────────────────── + +const STATUS_DOT: Record = { + queued: 'bg-[var(--c-subtle)]', + running: 'bg-[var(--c-brand)] animate-pulse', + succeeded: 'bg-[var(--c-success)]', + failed: 'bg-[var(--c-danger)]', + cancelled: 'bg-[var(--c-subtle)]', +} + +const STATUS_LABEL: Record = { + queued: 'Queued', + running: 'Running', + succeeded: 'Passed', + failed: 'Failed', + cancelled: 'Cancelled', +} + +const STATUS_TEXT: Record = { + queued: 'text-[var(--c-muted)]', + running: 'text-[var(--c-brand)]', + succeeded: 'text-[var(--c-success)]', + failed: 'text-[var(--c-danger)]', + cancelled: 'text-[var(--c-muted)]', +} + +// ── Row ─────────────────────────────────────────────────────────────────────── + +function RunRow({ run }: { + run: { + id: number + repoName: string + ownerName: string + triggerRef: string + triggerSha: string + triggeredBy: string + status: string + startedAt: string | null + finishedAt: string | null + createdAt: string + } +}) { + const status = (run.status as RunStatus) || 'queued' + return ( -
-

Pipelines

-
- - - -
-

No pipelines yet

-

- Pipelines run automatically when you push to a repository.
- Add a .forgebucket.yml file to get started. -

+ + {/* Status dot */} + + + {/* Repo + branch */} +
+
+ + {run.ownerName}/{run.repoName} + + + {STATUS_LABEL[status]} +
+
+ {shortRef(run.triggerRef)} + {shortSHA(run.triggerSha)} + by {run.triggeredBy} +
+
+ + {/* Duration + time */} +
+ + {duration(run.startedAt, run.finishedAt)} + + + {timeAgo(run.createdAt)} + +
+ + {/* Arrow */} + + + + + ) +} + +// ── Row skeleton ────────────────────────────────────────────────────────────── + +function RunRowSkeleton() { + return ( +
+ +
+ + +
+
+ +
) } + +// ── Filter bar ──────────────────────────────────────────────────────────────── + +type FilterStatus = 'all' | RunStatus + +const FILTERS: { label: string; value: FilterStatus }[] = [ + { label: 'All', value: 'all' }, + { label: 'Running', value: 'running' }, + { label: 'Failed', value: 'failed' }, + { label: 'Passed', value: 'succeeded' }, + { label: 'Queued', value: 'queued' }, +] + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function PipelinesPage() { + const { data: runs, isLoading } = useRecentRuns(50) + + // Simple client-side filter — real pagination could be added later + const [filter, setFilter] = useState('all') + + const filtered = runs?.filter(r => filter === 'all' || r.status === filter) ?? [] + + const runningCount = runs?.filter(r => r.status === 'running').length ?? 0 + const failedCount = runs?.filter(r => r.status === 'failed').length ?? 0 + + return ( +
+ + {/* Header */} +
+
+

Pipelines

+

+ Recent runs across all your repositories +

+
+ + {/* Live indicators */} + {!isLoading && (runningCount > 0 || failedCount > 0) && ( +
+ {runningCount > 0 && ( + + + {runningCount} running + + )} + {failedCount > 0 && ( + + + {failedCount} failed + + )} +
+ )} +
+ + {/* Filter tabs */} +
+ {FILTERS.map(f => ( + + ))} +
+ + {/* Runs list */} +
+ {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ) + ) : filtered.length === 0 ? ( +
+ + + +
+

+ {filter === 'all' ? 'No pipeline runs yet' : `No ${STATUS_LABEL[filter as RunStatus]?.toLowerCase()} runs`} +

+

+ {filter === 'all' + ? <>Push to a repository with a .forgebucket/workflows/*.yml file to trigger a run. + : 'Try a different filter above.'} +

+
+
+ ) : ( + filtered.map(run => ) + )} +
+
+ ) +} + + diff --git a/frontend/src/pages/RepoPipelinesPage.tsx b/frontend/src/pages/RepoPipelinesPage.tsx new file mode 100644 index 0000000..a10dc3e --- /dev/null +++ b/frontend/src/pages/RepoPipelinesPage.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { useRuns } from '../api/queries/pipelines' +import { Skeleton } from '../ui/Skeleton' +import { cn } from '../lib/utils' +import type { RunStatus } from '../types/api' + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const min = Math.floor(diff / 60_000) + if (min < 1) return 'just now' + if (min < 60) return `${min}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + return `${Math.floor(hr / 24)}d ago` +} + +function duration(start: string | null, end: string | null): string { + if (!start) return '—' + const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime() + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${s % 60}s` +} + +function shortRef(ref: string): string { + return ref.replace('refs/heads/', '').replace('refs/tags/', '') +} + +function shortSHA(sha: string): string { + return sha.slice(0, 7) +} + +// ── Status helpers ───────────────────────────────────────────────────────────── + +const STATUS_DOT: Record = { + queued: 'bg-[var(--c-subtle)]', + running: 'bg-[var(--c-brand)] animate-pulse', + succeeded: 'bg-[var(--c-success)]', + failed: 'bg-[var(--c-danger)]', + cancelled: 'bg-[var(--c-subtle)]', +} + +const STATUS_LABEL: Record = { + queued: 'Queued', + running: 'Running', + succeeded: 'Passed', + failed: 'Failed', + cancelled: 'Cancelled', +} + +const STATUS_TEXT: Record = { + queued: 'text-[var(--c-muted)]', + running: 'text-[var(--c-brand)]', + succeeded: 'text-[var(--c-success)]', + failed: 'text-[var(--c-danger)]', + cancelled: 'text-[var(--c-muted)]', +} + +// ── Row ─────────────────────────────────────────────────────────────────────── + +function RunRow({ run, owner, repo }: { + run: { + id: number + triggerRef: string + triggerSha: string + triggeredBy: string + status: string + startedAt: string | null + finishedAt: string | null + createdAt: string + } + owner: string + repo: string +}) { + const status = (run.status as RunStatus) || 'queued' + + return ( + + + +
+
+ + Run #{run.id} + + + {STATUS_LABEL[status]} + +
+
+ {shortRef(run.triggerRef)} + {shortSHA(run.triggerSha)} + by {run.triggeredBy} +
+
+ +
+ + {duration(run.startedAt, run.finishedAt)} + + + {timeAgo(run.createdAt)} + +
+ + + + + + ) +} + +function RunRowSkeleton() { + return ( +
+ +
+ + +
+
+ + +
+
+ ) +} + +// ── Filter tabs ─────────────────────────────────────────────────────────────── + +type FilterStatus = 'all' | RunStatus + +const FILTERS: { label: string; value: FilterStatus }[] = [ + { label: 'All', value: 'all' }, + { label: 'Running', value: 'running' }, + { label: 'Failed', value: 'failed' }, + { label: 'Passed', value: 'succeeded' }, + { label: 'Queued', value: 'queued' }, +] + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function RepoPipelinesPage() { + const { owner = '', repo = '' } = useParams() + const { data: runs, isLoading } = useRuns(owner, repo, 50) + const [filter, setFilter] = useState('all') + + const filtered = runs?.filter(r => filter === 'all' || r.status === filter) ?? [] + const runningCount = runs?.filter(r => r.status === 'running').length ?? 0 + const failedCount = runs?.filter(r => r.status === 'failed').length ?? 0 + + return ( +
+ + {/* Header */} +
+
+

Pipelines

+

+ Pipeline runs for {owner}/{repo} +

+
+ + {!isLoading && (runningCount > 0 || failedCount > 0) && ( +
+ {runningCount > 0 && ( + + + {runningCount} running + + )} + {failedCount > 0 && ( + + + {failedCount} failed + + )} +
+ )} +
+ + {/* Filter tabs */} +
+ {FILTERS.map(f => ( + + ))} +
+ + {/* Runs list */} +
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : filtered.length === 0 ? ( +
+ + + +
+

+ {filter === 'all' ? 'No pipeline runs yet' : `No ${STATUS_LABEL[filter as RunStatus]?.toLowerCase()} runs`} +

+

+ {filter === 'all' ? ( + <>Push to this repository with a .forgebucket/workflows/*.yml file to trigger a run. + ) : ( + 'Try a different filter above.' + )} +

+
+
+ ) : ( + filtered.map(run => ) + )} +
+
+ ) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 00b9383..380830c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -66,15 +66,76 @@ export interface Commit { date: string } +// Pipeline is a workflow definition file stored in the repository. export interface Pipeline { id: number repoId: number - ref: string - status: 'pending' | 'running' | 'success' | 'failure' | 'cancelled' + name: string + filePath: string createdAt: string updatedAt: string } +export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' + +// PipelineRun is a single execution triggered by a push. +export interface PipelineRun { + id: number + pipelineId: number + repoId: number + triggerRef: string // e.g. refs/heads/main + triggerSha: string // 40-char commit SHA + triggeredBy: string // username + status: RunStatus + startedAt: string | null + finishedAt: string | null + createdAt: string +} + +// PipelineJob is a single node in the run DAG. +export interface PipelineJob { + id: number + runId: number + name: string + image: string + needs: string // JSON array of dependency job names: '["build","test"]' + status: RunStatus + startedAt: string | null + finishedAt: string | null + createdAt: string +} + +// PipelineStep is a single command within a job. +export interface PipelineStep { + id: number + jobId: number + seq: number + name: string + runCmd: string + usesAction: string + status: RunStatus + exitCode: number + startedAt: string | null + finishedAt: string | null +} + +// PipelineStepLog is one append-only log chunk for a step. +export interface PipelineStepLog { + id: number + stepId: number + chunkIndex: number + content: string + createdAt: string +} + +// RunDetail is what GET /runs/:runId returns — run + jobs with steps. +export interface RunDetail extends PipelineRun { + jobs: Array +} + +// StepLogs is what GET /jobs/:jobId/logs returns. +export type StepLogs = Array + export type IssueState = 'open' | 'closed' export interface Issue { diff --git a/internal/api/handlers/dashboard.go b/internal/api/handlers/dashboard.go index 25f093d..a7bccd2 100644 --- a/internal/api/handlers/dashboard.go +++ b/internal/api/handlers/dashboard.go @@ -61,12 +61,27 @@ type dashRepo struct { OpenIssueCount int `json:"openIssueCount"` } +type dashRun struct { + ID int64 `json:"id"` + RepoID int64 `json:"repoId"` + RepoName string `json:"repoName"` + OwnerName string `json:"ownerName"` + TriggerRef string `json:"triggerRef"` + TriggerSHA string `json:"triggerSha"` + TriggeredBy string `json:"triggeredBy"` + Status string `json:"status"` + StartedAt *string `json:"startedAt"` + FinishedAt *string `json:"finishedAt"` + CreatedAt string `json:"createdAt"` +} + type dashboardResponse struct { Stats dashStats `json:"stats"` ReviewQueue []dashPR `json:"reviewQueue"` MyOpenPRs []dashPR `json:"myOpenPRs"` MyOpenIssues []dashIssue `json:"myOpenIssues"` Repos []dashRepo `json:"repos"` + RecentRuns []dashRun `json:"recentRuns"` } // ── Handler ─────────────────────────────────────────────────────────────────── @@ -211,6 +226,36 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) { }) } + // 7. Recent CI runs across user repos. + var recentRuns []models.PipelineRun + if len(repoIDs) > 0 { + h.db.In("repo_id", repoIDs).Desc("id").Limit(5).Find(&recentRuns) + } + runsDash := make([]dashRun, 0, len(recentRuns)) + for _, run := range recentRuns { + rp := repoByID[run.RepoID] + dr := dashRun{ + ID: run.ID, + RepoID: run.RepoID, + RepoName: rp.Name, + OwnerName: owner.Username, + TriggerRef: run.TriggerRef, + TriggerSHA: run.TriggerSHA, + TriggeredBy: run.TriggeredBy, + Status: run.Status, + CreatedAt: run.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + if run.StartedAt != nil { + s := run.StartedAt.Format("2006-01-02T15:04:05Z") + dr.StartedAt = &s + } + if run.FinishedAt != nil { + f := run.FinishedAt.Format("2006-01-02T15:04:05Z") + dr.FinishedAt = &f + } + runsDash = append(runsDash, dr) + } + resp := dashboardResponse{ Stats: dashStats{ RepoCount: len(repos), @@ -222,6 +267,7 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) { MyOpenPRs: myPRDash, MyOpenIssues: issueDash, Repos: dashRepos, + RecentRuns: runsDash, } jsonOK(w, resp) } diff --git a/internal/api/handlers/pipelines.go b/internal/api/handlers/pipelines.go index 6677e5d..4df7822 100644 --- a/internal/api/handlers/pipelines.go +++ b/internal/api/handlers/pipelines.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "xorm.io/xorm" + "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/models" ) @@ -19,6 +20,59 @@ func NewPipelineHandler(db *xorm.Engine) *PipelineHandler { return &PipelineHandler{db: db} } +// recentRunResponse extends PipelineRun with repo context for the global feed. +type recentRunResponse struct { + models.PipelineRun + RepoName string `json:"repoName"` + OwnerName string `json:"ownerName"` +} + +// ListRecentRuns returns recent runs across all repos owned by the current user. +// GET /api/v1/pipelines/runs +func (h *PipelineHandler) ListRecentRuns(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.UserIDFromContext(r.Context()) + + limit := 30 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { + limit = l + } + + // Repos owned by this user. + var repos []models.Repository + h.db.Where("owner_id = ?", userID).Cols("id", "name").Find(&repos) + if len(repos) == 0 { + jsonOK(w, []recentRunResponse{}) + return + } + + repoIDs := make([]int64, len(repos)) + repoNameByID := make(map[int64]string, len(repos)) + for i, rp := range repos { + repoIDs[i] = rp.ID + repoNameByID[rp.ID] = rp.Name + } + + // Owner username. + var owner models.User + h.db.ID(userID).Cols("username").Get(&owner) + + var runs []models.PipelineRun + h.db.In("repo_id", repoIDs).Desc("id").Limit(limit).Find(&runs) + if runs == nil { + runs = []models.PipelineRun{} + } + + result := make([]recentRunResponse, len(runs)) + for i, run := range runs { + result[i] = recentRunResponse{ + PipelineRun: run, + RepoName: repoNameByID[run.RepoID], + OwnerName: owner.Username, + } + } + jsonOK(w, result) +} + // ListPipelines returns all pipeline definitions for a repository. func (h *PipelineHandler) ListPipelines(w http.ResponseWriter, r *http.Request) { repoID, ok := h.repoID(w, r) diff --git a/internal/api/router.go b/internal/api/router.go index fa56bee..cd17a8b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -104,6 +104,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even r.Get("/me", userH.Me) r.Get("/dashboard", dashH.Get) r.Get("/audit", auditH.List) + r.Get("/pipelines/runs", pipeH.ListRecentRuns) r.Route("/admin", func(r chi.Router) { r.Get("/runners", runnerH.List)