pipeline dag visualization + Dashboard command center upgrade + command palette wiring. fixed repo pipeline page.
This commit is contained in:
@@ -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() {
|
||||
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls/new" element={<S><CreatePRPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pipelines" element={<S><RepoPipelinesPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
|
||||
|
||||
<Route path="starred" element={<S><StarredPage /></S>} />
|
||||
<Route path="pulls" element={<S><PRsPage /></S>} />
|
||||
|
||||
@@ -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<typeof dashboardSchema>
|
||||
export type DashPR = z.infer<typeof dashPRSchema>
|
||||
export type DashIssue = z.infer<typeof dashIssueSchema>
|
||||
export type DashRepo = z.infer<typeof dashRepoSchema>
|
||||
export type DashRun = z.infer<typeof dashRunSchema>
|
||||
|
||||
export function useDashboard() {
|
||||
return useQuery({
|
||||
|
||||
@@ -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<Pipeline[]>(`/api/v1/repos/${owner}/${repo}/pipelines`, pipelinesSchema),
|
||||
api.get<Pipeline[]>(`/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<Pipeline>(
|
||||
`/api/v1/repos/${owner}/${repo}/pipelines/${runId}`,
|
||||
pipelineSchema,
|
||||
api.get<PipelineRun[]>(
|
||||
`/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<RunDetail>(
|
||||
`/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<StepLogs>(
|
||||
`/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<RecentRun[]>(`/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(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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<Pipeline['status'], string> = {
|
||||
pending: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
|
||||
const STATUS_COLOR: Record<RunStatus, string> = {
|
||||
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<Pipeline['status'], string> = {
|
||||
pending: 'bg-[var(--c-subtle)]',
|
||||
const STATUS_DOT: Record<RunStatus, string> = {
|
||||
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<Pipeline['status'], string> = {
|
||||
pending: 'Pending',
|
||||
const STATUS_LABEL: Record<RunStatus, string> = {
|
||||
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<string, number>()
|
||||
|
||||
function getDepth(name: string, visited = new Set<string>()): 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 (
|
||||
<div className="border border-[var(--c-border)] rounded p-4 bg-[var(--c-surface)]">
|
||||
{/* Pipeline header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('w-2.5 h-2.5 rounded-full shrink-0', STATUS_DOT[pipeline.status])} />
|
||||
<span className="text-sm font-semibold text-[var(--c-text)]">
|
||||
Pipeline #{pipeline.id}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs font-medium px-2 py-0.5 rounded-full border',
|
||||
STATUS_COLOR[pipeline.status],
|
||||
)}>
|
||||
{STATUS_LABEL[pipeline.status]}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--c-muted)]">{pipeline.ref}</span>
|
||||
</div>
|
||||
|
||||
{/* Waterfall stages */}
|
||||
<div className="flex items-center gap-0 overflow-x-auto pb-2">
|
||||
{resolvedStages.map((stage, i) => (
|
||||
<div key={stage.name} className="flex items-center shrink-0">
|
||||
{/* Stage box */}
|
||||
<div className={cn(
|
||||
'flex flex-col items-center justify-center px-4 py-3 rounded border text-center min-w-[80px]',
|
||||
STATUS_COLOR[stage.status],
|
||||
)}>
|
||||
<StatusIcon status={stage.status} />
|
||||
<span className="text-[11px] font-semibold mt-1">{stage.name}</span>
|
||||
{stage.duration && (
|
||||
<span className="text-[10px] opacity-70 mt-0.5">{stage.duration}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connector arrow (not after last) */}
|
||||
{i < resolvedStages.length - 1 && (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="h-px w-4 bg-[var(--c-border)]" />
|
||||
<svg width="6" height="8" viewBox="0 0 6 8" fill="var(--c-border)">
|
||||
<path d="M0 0l6 4-6 4V0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
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 (
|
||||
<svg width="16" height="16" fill="none" stroke="var(--c-success)" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (status === 'failure') {
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<svg width="16" height="16" fill="none" stroke="var(--c-danger)" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
@@ -143,9 +98,101 @@ function StatusIcon({ status }: { status: Pipeline['status'] }) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return (
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
// queued
|
||||
return (
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PipelineWaterfall({
|
||||
jobs,
|
||||
runStatus,
|
||||
runId,
|
||||
onSelectJob,
|
||||
selectedJobId,
|
||||
}: PipelineWaterfallProps) {
|
||||
const columns = topoColumns(jobs)
|
||||
|
||||
return (
|
||||
<div className="border border-[var(--c-border)] rounded p-4 bg-[var(--c-surface)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('w-2.5 h-2.5 rounded-full shrink-0', STATUS_DOT[runStatus])} />
|
||||
<span className="text-sm font-semibold text-[var(--c-text)]">
|
||||
Run #{runId}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs font-medium px-2 py-0.5 rounded-full border',
|
||||
STATUS_COLOR[runStatus],
|
||||
)}>
|
||||
{STATUS_LABEL[runStatus]}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--c-muted)]">
|
||||
{jobs.length} job{jobs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* DAG waterfall */}
|
||||
{columns.length === 0 ? (
|
||||
<p className="text-xs text-[var(--c-muted)] text-center py-4">No jobs.</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-0 overflow-x-auto pb-2">
|
||||
{columns.map((col, colIdx) => (
|
||||
<div key={colIdx} className="flex items-center shrink-0">
|
||||
{/* Column */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{col.map(job => {
|
||||
const status = (job.status as RunStatus) || 'queued'
|
||||
const isSelected = selectedJobId === job.id
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
onClick={() => onSelectJob?.(job.id)}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center px-4 py-3 rounded border text-center min-w-[88px] transition-all',
|
||||
STATUS_COLOR[status],
|
||||
onSelectJob ? 'cursor-pointer' : 'cursor-default',
|
||||
isSelected ? 'ring-2 ring-[var(--c-brand)] ring-offset-1' : '',
|
||||
)}
|
||||
>
|
||||
<StatusIcon status={status} />
|
||||
<span className="text-[11px] font-semibold mt-1 truncate max-w-[72px]">{job.name}</span>
|
||||
{(job.startedAt || job.finishedAt) && (
|
||||
<span className="text-[10px] opacity-70 mt-0.5">
|
||||
{duration(job.startedAt, job.finishedAt)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Connector (not after last column) */}
|
||||
{colIdx < columns.length - 1 && (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="h-px w-4 bg-[var(--c-border)]" />
|
||||
<svg width="6" height="8" viewBox="0 0 6 8" fill="var(--c-border)">
|
||||
<path d="M0 0l6 4-6 4V0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>(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() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
)
|
||||
if (t === 'run') return (
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
)
|
||||
return (
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||
@@ -110,7 +130,7 @@ function CommandPalette() {
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<span className="flex-1 text-left">Search repos, PRs, issues…</span>
|
||||
<span className="flex-1 text-left">Search repos, PRs, issues, pipelines…</span>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono border border-[var(--c-border)] rounded text-[var(--c-subtle)] group-hover:border-[var(--c-brand-focus)]">
|
||||
<span>⌘</span><span>K</span>
|
||||
</kbd>
|
||||
@@ -126,7 +146,7 @@ function CommandPalette() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<input ref={inputRef} value={q} onChange={e => 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" />
|
||||
<kbd className="text-[10px] font-mono text-[var(--c-subtle)] border border-[var(--c-border)] rounded px-1.5 py-0.5">esc</kbd>
|
||||
</div>
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<Link to={`/repos/${run.ownerName}/${run.repoName}/runs/${run.id}`}
|
||||
className="flex items-center gap-2.5 px-3.5 py-2.5 hover:bg-[var(--c-surface-muted)] transition-colors group">
|
||||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${RUN_DOT[run.status] ?? 'bg-[var(--c-subtle)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] truncate transition-colors">
|
||||
{run.repoName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[var(--c-muted)]">{branch}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--c-muted)] mt-0.5 font-mono">{sha}</p>
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium shrink-0 ${run.status === 'failed' ? 'text-[var(--c-danger)]' : run.status === 'running' ? 'text-[var(--c-brand)]' : run.status === 'succeeded' ? 'text-[var(--c-success)]' : 'text-[var(--c-muted)]'}`}>
|
||||
{RUN_LABEL[run.status] ?? run.status}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Empty({ message, action }: { message: string; action?: React.ReactNode }) {
|
||||
@@ -502,7 +564,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* CI/CD — placeholder until pipelines are implemented */}
|
||||
{/* CI / CD — live recent runs */}
|
||||
<Section
|
||||
title="CI / CD"
|
||||
icon={
|
||||
@@ -510,18 +572,26 @@ export default function DashboardPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
}
|
||||
action={
|
||||
<Link to="/pipelines" className="text-[10px] text-[var(--c-brand)] hover:underline">All runs</Link>
|
||||
}
|
||||
>
|
||||
<Panel>
|
||||
<div className="px-3.5 py-5 flex flex-col items-center gap-2 text-center">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--c-surface-muted)] flex items-center justify-center">
|
||||
<svg width="14" height="14" fill="none" stroke="var(--c-muted)" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--c-muted)]">Pipeline integration coming soon.</p>
|
||||
<Link to="/pipelines" className="text-[10px] text-[var(--c-brand)] hover:underline">View pipelines →</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
{isLoading ? <PanelSkeleton rows={3} /> : (
|
||||
<Panel>
|
||||
{!dash?.recentRuns?.length ? (
|
||||
<Empty
|
||||
message="No pipeline runs yet."
|
||||
action={
|
||||
<p className="text-[10px] text-[var(--c-muted)] mt-1">
|
||||
Push to a repo with a <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">.forgebucket/workflows/</code> file.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
dash.recentRuns.map(run => <CIRunRow key={run.id} run={run} />)
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Quick actions */}
|
||||
|
||||
@@ -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<RunStatus, string> = {
|
||||
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<RunStatus, string> = {
|
||||
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<RunStatus, string> = {
|
||||
queued: 'Queued',
|
||||
running: 'Running',
|
||||
succeeded: 'Passed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
function StatusBadge({ status, size = 'md' }: { status: RunStatus; size?: 'sm' | 'md' }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1.5 font-medium border rounded-full',
|
||||
size === 'sm' ? 'text-[10px] px-1.5 py-px' : 'text-xs px-2.5 py-1',
|
||||
STATUS_BADGE[status],
|
||||
)}>
|
||||
<span className={cn('rounded-full shrink-0', size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2', STATUS_DOT[status])} />
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-[var(--c-muted)]">
|
||||
No jobs in this run.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex items-start gap-0 min-w-max">
|
||||
{columns.map((col, colIdx) => (
|
||||
<div key={colIdx} className="flex items-center">
|
||||
{/* Column of jobs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{col.map(job => (
|
||||
<button
|
||||
key={job.id}
|
||||
onClick={() => onSelectJob(job.id)}
|
||||
className={cn(
|
||||
'flex flex-col gap-1.5 px-4 py-3 rounded-lg border text-left min-w-[140px] transition-all',
|
||||
selectedJobId === job.id
|
||||
? 'ring-2 ring-[var(--c-brand)] border-[var(--c-brand-focus)]'
|
||||
: 'hover:border-[var(--c-brand-focus)]',
|
||||
STATUS_BADGE[job.status as RunStatus] || STATUS_BADGE.queued,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<JobStatusIcon status={job.status as RunStatus} />
|
||||
<span className="text-xs font-semibold truncate max-w-[100px]">{job.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-70">
|
||||
{duration(job.startedAt, job.finishedAt)}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono opacity-60 truncate">{job.image}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Connector (not after last column) */}
|
||||
{colIdx < columns.length - 1 && (
|
||||
<div className="flex items-center px-2 self-center">
|
||||
<div className="h-px w-4 bg-[var(--c-border)]" />
|
||||
<svg width="5" height="8" viewBox="0 0 5 8" fill="var(--c-border)">
|
||||
<path d="M0 0l5 4-5 4V0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JobStatusIcon({ status }: { status: RunStatus }) {
|
||||
if (status === 'succeeded') {
|
||||
return <svg width="13" height="13" fill="none" stroke="var(--c-success)" strokeWidth="2.5" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return <svg width="13" height="13" fill="none" stroke="var(--c-danger)" strokeWidth="2.5" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
|
||||
}
|
||||
if (status === 'running') {
|
||||
return <svg width="13" height="13" fill="none" stroke="var(--c-brand)" strokeWidth="2" viewBox="0 0 24 24" className="animate-spin"><path strokeLinecap="round" d="M12 3a9 9 0 1 0 9 9" /></svg>
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" /></svg>
|
||||
}
|
||||
// queued
|
||||
return <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /></svg>
|
||||
}
|
||||
|
||||
/** 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<string, number>()
|
||||
|
||||
function getDepth(name: string, visited = new Set<string>()): 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<Set<number>>(new Set([0]))
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-8 rounded" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
return <p className="text-xs text-[var(--c-muted)] p-4">No steps recorded for this job.</p>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="divide-y divide-[var(--c-border)]">
|
||||
{data.map(step => {
|
||||
const open = expandedSteps.has(step.seq)
|
||||
const logText = step.logs.map(l => l.content).join('')
|
||||
return (
|
||||
<div key={step.id}>
|
||||
<button
|
||||
onClick={() => toggle(step.seq)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-[var(--c-surface-muted)] transition-colors"
|
||||
>
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
width="12" height="12" fill="none" stroke="var(--c-muted)" strokeWidth="2" viewBox="0 0 24 24"
|
||||
className={cn('shrink-0 transition-transform', open ? 'rotate-90' : '')}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
<JobStatusIcon status={step.status as RunStatus} />
|
||||
<span className="flex-1 text-xs font-medium text-[var(--c-text)] truncate">
|
||||
{step.name || step.runCmd || step.usesAction || `Step ${step.seq + 1}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--c-muted)] shrink-0">
|
||||
{duration(step.startedAt, step.finishedAt)}
|
||||
</span>
|
||||
{step.exitCode !== 0 && step.status === 'failed' && (
|
||||
<span className="text-[10px] font-mono text-[var(--c-danger)] shrink-0">
|
||||
exit {step.exitCode}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="bg-[#0d1117] px-4 py-3 overflow-x-auto">
|
||||
{logText ? (
|
||||
<pre className="text-[11px] leading-5 font-mono text-[#c9d1d9] whitespace-pre-wrap break-words">
|
||||
{logText}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-[11px] text-[#8b949e] font-mono italic">No output captured.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<number | null>(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 (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12 text-center">
|
||||
<p className="text-sm text-[var(--c-muted)]">Run not found.</p>
|
||||
<Link to={`/repos/${owner}/${repo}`} className="mt-4 inline-block text-xs text-[var(--c-brand)] hover:underline">
|
||||
← Back to repository
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-6 py-5 space-y-5">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-[var(--c-muted)]">
|
||||
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)] transition-colors">
|
||||
{owner}/{repo}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)] transition-colors">
|
||||
Runs
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--c-text)]">#{runId}</span>
|
||||
</nav>
|
||||
|
||||
{/* Run header */}
|
||||
{isLoading ? (
|
||||
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-48 rounded" />
|
||||
<Skeleton className="h-3.5 w-80 rounded" />
|
||||
</div>
|
||||
) : run ? (
|
||||
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-base font-semibold text-[var(--c-text)]">
|
||||
Run #{run.id}
|
||||
</h1>
|
||||
<StatusBadge status={run.status as RunStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--c-muted)] flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
|
||||
</svg>
|
||||
<span className="font-mono">{shortRef(run.triggerRef)}</span>
|
||||
</span>
|
||||
<span className="font-mono text-[var(--c-muted)]">{shortSHA(run.triggerSha)}</span>
|
||||
<span>triggered by {run.triggeredBy}</span>
|
||||
<span>{timeAgo(run.createdAt)}</span>
|
||||
{run.startedAt && (
|
||||
<span>duration: {duration(run.startedAt, run.finishedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(run.status === 'running' || run.status === 'queued') && (
|
||||
<button
|
||||
onClick={() => cancelRun.mutate(run.id)}
|
||||
disabled={cancelRun.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:border-[var(--c-danger)] hover:text-[var(--c-danger)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5A2.25 2.25 0 0 1 7.5 5.25h9a2.25 2.25 0 0 1 2.25 2.25v9a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25v-9Z" />
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* DAG + log viewer */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
|
||||
{/* DAG section */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-2.5">
|
||||
Jobs
|
||||
</h2>
|
||||
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex gap-3">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-20 w-36 rounded-lg" />)}
|
||||
</div>
|
||||
) : (
|
||||
<DAGView
|
||||
jobs={jobs}
|
||||
selectedJobId={effectiveJobId}
|
||||
onSelectJob={id => setSelectedJobId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Log viewer for selected job */}
|
||||
{effectiveJobId !== null && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)]">
|
||||
{selectedJob ? `Logs — ${selectedJob.name}` : 'Logs'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedJob && (
|
||||
<StatusBadge status={selectedJob.status as RunStatus} size="sm" />
|
||||
)}
|
||||
{selectedJob && (selectedJob.status === 'failed' || selectedJob.status === 'cancelled') && (
|
||||
<button
|
||||
onClick={() => retryJob.mutate(effectiveJobId)}
|
||||
disabled={retryJob.isPending}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:border-[var(--c-brand-focus)] hover:text-[var(--c-brand)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
|
||||
<LogViewer owner={owner} repo={repo} runId={runIdNum} jobId={effectiveJobId} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<RunStatus, string> = {
|
||||
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<RunStatus, string> = {
|
||||
queued: 'Queued',
|
||||
running: 'Running',
|
||||
succeeded: 'Passed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<RunStatus, string> = {
|
||||
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 (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
||||
<h1 className="text-xl font-semibold text-[var(--c-text)]">Pipelines</h1>
|
||||
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[var(--c-border)] rounded text-center gap-3">
|
||||
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">No pipelines yet</p>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1 max-w-xs">
|
||||
Pipelines run automatically when you push to a repository.<br />
|
||||
Add a <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">.forgebucket.yml</code> file to get started.
|
||||
</p>
|
||||
<Link
|
||||
to={`/repos/${run.ownerName}/${run.repoName}/runs/${run.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--c-surface-muted)] transition-colors group"
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span className={cn('w-2 h-2 rounded-full shrink-0', STATUS_DOT[status])} />
|
||||
|
||||
{/* Repo + branch */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] transition-colors">
|
||||
{run.ownerName}/{run.repoName}
|
||||
</span>
|
||||
<span className={cn('text-xs font-medium', STATUS_TEXT[status])}>
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 mt-0.5 text-[11px] text-[var(--c-muted)]">
|
||||
<span className="font-mono">{shortRef(run.triggerRef)}</span>
|
||||
<span className="font-mono">{shortSHA(run.triggerSha)}</span>
|
||||
<span>by {run.triggeredBy}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration + time */}
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
<span className="text-[11px] text-[var(--c-muted)] font-mono">
|
||||
{duration(run.startedAt, run.finishedAt)}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--c-subtle)]">
|
||||
{timeAgo(run.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg className="shrink-0 text-[var(--c-border)] group-hover:text-[var(--c-brand)] transition-colors" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Row skeleton ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RunRowSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="w-2 h-2 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-48 rounded" />
|
||||
<Skeleton className="h-2.5 w-32 rounded" />
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Skeleton className="h-3 w-10 rounded" />
|
||||
<Skeleton className="h-2.5 w-12 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<FilterStatus>('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 (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-5 space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--c-text)]">Pipelines</h1>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
||||
Recent runs across all your repositories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live indicators */}
|
||||
{!isLoading && (runningCount > 0 || failedCount > 0) && (
|
||||
<div className="flex items-center gap-2.5 text-xs">
|
||||
{runningCount > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-[var(--c-brand)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--c-brand)] animate-pulse" />
|
||||
{runningCount} running
|
||||
</span>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-[var(--c-danger)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--c-danger)]" />
|
||||
{failedCount} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-[var(--c-border)]">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
|
||||
filter === f.value
|
||||
? 'border-[var(--c-brand)] text-[var(--c-brand)]'
|
||||
: 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
{f.value !== 'all' && !isLoading && runs && (
|
||||
<span className="ml-1.5 text-[10px] font-mono text-[var(--c-subtle)]">
|
||||
{runs.filter(r => r.status === f.value).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Runs list */}
|
||||
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden divide-y divide-[var(--c-border)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => <RunRowSkeleton key={i} />)
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">
|
||||
{filter === 'all' ? 'No pipeline runs yet' : `No ${STATUS_LABEL[filter as RunStatus]?.toLowerCase()} runs`}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1 max-w-xs">
|
||||
{filter === 'all'
|
||||
? <>Push to a repository with a <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">.forgebucket/workflows/*.yml</code> file to trigger a run.</>
|
||||
: 'Try a different filter above.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(run => <RunRow key={run.id} run={run} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<RunStatus, string> = {
|
||||
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<RunStatus, string> = {
|
||||
queued: 'Queued',
|
||||
running: 'Running',
|
||||
succeeded: 'Passed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<RunStatus, string> = {
|
||||
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 (
|
||||
<Link
|
||||
to={`/repos/${owner}/${repo}/runs/${run.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--c-surface-muted)] transition-colors group"
|
||||
>
|
||||
<span className={cn('w-2 h-2 rounded-full shrink-0', STATUS_DOT[status])} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] transition-colors">
|
||||
Run #{run.id}
|
||||
</span>
|
||||
<span className={cn('text-xs font-medium', STATUS_TEXT[status])}>
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 mt-0.5 text-[11px] text-[var(--c-muted)]">
|
||||
<span className="font-mono">{shortRef(run.triggerRef)}</span>
|
||||
<span className="font-mono">{shortSHA(run.triggerSha)}</span>
|
||||
<span>by {run.triggeredBy}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
<span className="text-[11px] text-[var(--c-muted)] font-mono">
|
||||
{duration(run.startedAt, run.finishedAt)}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--c-subtle)]">
|
||||
{timeAgo(run.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<svg className="shrink-0 text-[var(--c-border)] group-hover:text-[var(--c-brand)] transition-colors" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function RunRowSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="w-2 h-2 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-40 rounded" />
|
||||
<Skeleton className="h-2.5 w-28 rounded" />
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Skeleton className="h-3 w-10 rounded" />
|
||||
<Skeleton className="h-2.5 w-12 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<FilterStatus>('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 (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-5 space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--c-text)]">Pipelines</h1>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
||||
Pipeline runs for <span className="font-mono">{owner}/{repo}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isLoading && (runningCount > 0 || failedCount > 0) && (
|
||||
<div className="flex items-center gap-2.5 text-xs">
|
||||
{runningCount > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-[var(--c-brand)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--c-brand)] animate-pulse" />
|
||||
{runningCount} running
|
||||
</span>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-[var(--c-danger)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--c-danger)]" />
|
||||
{failedCount} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-[var(--c-border)]">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
|
||||
filter === f.value
|
||||
? 'border-[var(--c-brand)] text-[var(--c-brand)]'
|
||||
: 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
{f.value !== 'all' && !isLoading && runs && (
|
||||
<span className="ml-1.5 text-[10px] font-mono text-[var(--c-subtle)]">
|
||||
{runs.filter(r => r.status === f.value).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Runs list */}
|
||||
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden divide-y divide-[var(--c-border)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => <RunRowSkeleton key={i} />)
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">
|
||||
{filter === 'all' ? 'No pipeline runs yet' : `No ${STATUS_LABEL[filter as RunStatus]?.toLowerCase()} runs`}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1 max-w-xs">
|
||||
{filter === 'all' ? (
|
||||
<>Push to this repository with a <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">.forgebucket/workflows/*.yml</code> file to trigger a run.</>
|
||||
) : (
|
||||
'Try a different filter above.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(run => <RunRow key={run.id} run={run} owner={owner} repo={repo} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<PipelineJob & { steps: PipelineStep[] }>
|
||||
}
|
||||
|
||||
// StepLogs is what GET /jobs/:jobId/logs returns.
|
||||
export type StepLogs = Array<PipelineStep & { logs: PipelineStepLog[] }>
|
||||
|
||||
export type IssueState = 'open' | 'closed'
|
||||
|
||||
export interface Issue {
|
||||
|
||||
Reference in New Issue
Block a user