import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Link, useSearchParams } from "react-router"; import { ArchiveBoxIcon, ChevronDownIcon, CommandLineIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { EllipsisVerticalIcon } from "@heroicons/react/20/solid"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { useSWRConfig } from "swr"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import type { DragEndEvent } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { ciConfig, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunListItem } from "../data/runs"; import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; import { formatRelativeTime } from "../lib/format"; import { EmptyState } from "../components/state"; import { useToast } from "../components/toast"; import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events"; import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; import { archiveRun, canArchive } from "../lib/run-actions"; import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client"; export { shouldRefreshBoardForEvent }; export function meta({}: any) { return [{ title: "Runs — Fabro" }]; } interface ColumnStyle { iconType: "branch" | "pr"; actions: string[]; } const columnStyles: Record = { queued: { iconType: "branch", actions: [] }, initializing: { iconType: "branch", actions: [] }, running: { iconType: "branch", actions: [] }, blocked: { iconType: "branch", actions: ["Answer Question"] }, succeeded: { iconType: "pr", actions: [] }, failed: { iconType: "branch", actions: [] }, archived: { iconType: "branch", actions: [] }, }; const defaultColumnStyle: ColumnStyle = { iconType: "branch", actions: [] }; const defaultColumnColors = { dot: "bg-fg-muted", text: "text-fg-muted" }; interface BoardRunsResponse { columns: PaginatedBoardRunList["columns"]; data: PaginatedBoardRunList["data"]; meta: PaginatedBoardRunList["meta"]; } type Column = { id: ColumnStatus; name: string; dot: string; text: string; iconType: "branch" | "pr"; actions: string[]; items: RunItem[]; }; function buildSkeletonColumns(includeArchived: boolean): Column[] { return columnStatuses .filter((id) => includeArchived || id !== "archived") .map((id) => { const colors = columnStatusDisplay[id]; return { id, name: colors.label, dot: colors.dot, text: colors.text, ...(columnStyles[id] ?? defaultColumnStyle), items: [], }; }); } export function buildBoardColumns(response: BoardRunsResponse): Column[] { const grouped = new Map(); for (const col of response.columns) { grouped.set(col.id, []); } for (const apiRun of response.data) { if (grouped.has(apiRun.column)) { grouped.get(apiRun.column)?.push(mapRunListItem(apiRun)); } } return response.columns.map((col) => { const id = col.id; const colors = columnStatusDisplay[id] ?? defaultColumnColors; return { id, name: col.name, dot: colors.dot, text: colors.text, ...(columnStyles[id] ?? defaultColumnStyle), items: grouped.get(col.id) ?? [], }; }); } function boardLifecycleStatusLabel(run: Pick): string | null { if (run.lifecycleStatusLabel == null) return null; if (run.column === "initializing") return null; if (run.column != null && columnStatusDisplay[run.column]?.label === run.lifecycleStatusLabel) { return null; } return run.lifecycleStatusLabel; } function listLifecycleStatusLabel(run: Pick): string | null { if (run.lifecycleStatusLabel == null || run.lifecycleStatusLabel === run.statusLabel) { return null; } return run.lifecycleStatusLabel; } function GitBranchIcon({ className }: { className?: string }) { return ( ); } function GitPullRequestIcon({ className }: { className?: string }) { return ( ); } const iconMap = { branch: GitBranchIcon, pr: GitPullRequestIcon, }; function CheckStatusIcon({ status }: { status: CheckStatus }) { switch (status) { case "success": return ( ); case "failure": return ( ); case "pending": return ( ); case "queued": return ( ); case "skipped": return ( ); } } function SummaryStatusIcon({ status }: { status: CiStatus }) { switch (status) { case "passing": return ( ); case "failing": return ( ); case "pending": return ( ); } } function summarizeChecks(checks: CheckRun[]) { const counts = { success: checks.filter((c) => c.status === "success").length, failure: checks.filter((c) => c.status === "failure").length, skipped: checks.filter((c) => c.status === "skipped").length, pending: checks.filter((c) => c.status === "pending" || c.status === "queued").length, }; let summary: string; const parts: string[] = []; if (counts.failure > 0) { summary = `${counts.failure} failing check${counts.failure !== 1 ? "s" : ""}`; if (counts.success > 0) parts.push(`${counts.success} success`); if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`); if (counts.pending > 0) parts.push(`${counts.pending} pending`); } else if (counts.pending > 0) { summary = `${counts.pending} check${counts.pending !== 1 ? "s" : ""} pending`; if (counts.success > 0) parts.push(`${counts.success} success`); if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`); } else { summary = "All checks passing"; if (counts.skipped > 0) { parts.push(`${counts.skipped} skipped`); parts.push(`${counts.success} success`); } } return { summary, detail: parts.join(", ") }; } function ChecksStatus({ checks }: { checks: CheckRun[] }) { const [expanded, setExpanded] = useState(false); const overallStatus = deriveCiStatus(checks); const config = ciConfig[overallStatus]; const { summary, detail } = summarizeChecks(checks); return (
{ e.preventDefault(); e.stopPropagation(); }} onKeyDown={(e) => { e.stopPropagation(); }} >
{checks.map((check) => (
{check.name} {check.duration ?? (check.status === "skipped" ? "skipped" : check.status === "queued" ? "queued" : "")}
))}
); } export const handle = { wide: true, }; function PrCard({ pr, icon: Icon, iconColor, actions, }: { pr: RunItem; icon: React.ComponentType<{ className?: string }>; iconColor: string; actions?: string[]; }) { const lifecycleLabel = boardLifecycleStatusLabel(pr); return (
{pr.repo} {pr.number != null && ( #{pr.number} )} {lifecycleLabel != null && ( {lifecycleLabel} )}

{pr.title}

{(pr.additions != null || pr.resources != null || pr.elapsed != null) && (
{pr.resources != null && ( {pr.resources} )} {pr.additions != null && pr.deletions != null && ( <> +{pr.additions.toLocaleString()} -{pr.deletions.toLocaleString()} )} {pr.comments != null && ( {pr.comments} )} {pr.elapsed != null && ( {pr.elapsed} )}
)} {pr.checks != null && } {pr.question != null && (

{pr.question}

)} {actions != null && actions.length > 0 && (
{actions?.map((label) => ( ))}
)} ); } function SortablePrCard({ pr, icon, iconColor, actions, }: { pr: RunItem; icon: React.ComponentType<{ className?: string }>; iconColor: string; actions?: string[]; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pr.id }); const wasDragging = useRef(false); if (isDragging) wasDragging.current = true; const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : undefined, position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; return (
{ if (wasDragging.current) { e.preventDefault(); e.stopPropagation(); wasDragging.current = false; } }} >
); } function archivableItems(items: RunItem[]): RunItem[] { return items.filter((item) => canArchive(item.lifecycleStatus)); } function ColumnActionsMenu({ column }: { column: Column }) { const archivable = archivableItems(column.items); const [pending, setPending] = useState(false); const { mutate } = useSWRConfig(); const { push } = useToast(); if (archivable.length === 0) return null; async function handleArchiveAll() { if (pending) return; setPending(true); const total = archivable.length; try { const results = await Promise.allSettled( archivable.map((item) => archiveRun(item.id)), ); const succeeded = results.filter((r) => r.status === "fulfilled").length; const failed = total - succeeded; const runWord = (n: number) => (n === 1 ? "run" : "runs"); if (failed === 0) { push({ message: `Archived ${total} ${runWord(total)}.` }); } else if (succeeded === 0) { push({ message: `Couldn't archive ${total} ${runWord(total)}. Try again.`, tone: "error", }); } else { push({ message: `Archived ${succeeded} of ${total} runs. ${failed} failed.`, tone: "error", }); } } finally { setPending(false); void mutate(queryKeys.boards.runs()); } } const label = pending ? `Archiving ${archivable.length}…` : `Archive all`; return ( ); } function BoardColumn({ column }: { column: Column }) { const Icon = iconMap[column.iconType]; const actions = column.actions; return (

{column.name}

{column.items.length}
pr.id)} strategy={verticalListSortingStrategy}>
{column.items.map((pr) => ( ))}
); } type ViewMode = "columns" | "list"; type CreatedFilter = "all" | "today" | "1h" | "1d" | "7d" | "30d"; const createdFilterOptions: { value: CreatedFilter; label: string }[] = [ { value: "all", label: "All time" }, { value: "today", label: "Today" }, { value: "1h", label: "Last hour" }, { value: "1d", label: "Last day" }, { value: "7d", label: "Last 7 days" }, { value: "30d", label: "Last 30 days" }, ]; function parseCreatedFilter(raw: string | null): CreatedFilter { switch (raw) { case "today": case "1h": case "1d": case "7d": case "30d": return raw; default: return "all"; } } function parseView(raw: string | null): ViewMode { return raw === "list" ? "list" : "columns"; } function createdCutoffMsFor(filter: CreatedFilter): number | null { const now = Date.now(); switch (filter) { case "all": return null; case "today": { const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); } case "1h": return now - 60 * 60 * 1000; case "1d": return now - 24 * 60 * 60 * 1000; case "7d": return now - 7 * 24 * 60 * 60 * 1000; case "30d": return now - 30 * 24 * 60 * 60 * 1000; } } function RunRow({ run }: { run: RunWithStatus }) { const lifecycleLabel = listLifecycleStatusLabel(run); const statusDisplay = columnStatusDisplay[run.status]; return ( {run.elapsed} {run.repo} {run.title} {lifecycleLabel != null && ( {lifecycleLabel} )} {run.comments != null && run.comments > 0 && ( {run.comments} )} {run.workflow} {run.createdAt != null ? formatRelativeTime(run.createdAt) : ""} {run.additions != null && +{run.additions.toLocaleString()}} {run.deletions != null && -{run.deletions.toLocaleString()}} {run.number != null && ( <> #{run.number} {run.checks != null && } )} ); } function TerminalLine({ prompt, command }: { prompt: string; command: string }) { return (
{prompt} {command}
); } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); function handleCopy() { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); } return ( ); } export function runsQuickStartCommands( hasGitHubAuth: boolean, serverUrl?: string, ) { return [ hasGitHubAuth && serverUrl ? `fabro auth login --server ${serverUrl}` : null, "fabro repo init", "fabro run hello", ].filter((command): command is string => command !== null); } function RunsLandingEmpty({ hasGitHubAuth, serverUrl, }: { hasGitHubAuth: boolean; serverUrl?: string; }) { const quickStartCommands = runsQuickStartCommands(hasGitHubAuth, serverUrl); return (

Your runs will appear here.

Quick start
{quickStartCommands.map((command) => ( ))}
); } export default function Runs() { const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get("search") ?? ""; const repoFilter = searchParams.get("repo") ?? "all"; const workflowFilter = searchParams.get("workflow") ?? "all"; const createdFilter = parseCreatedFilter(searchParams.get("created")); const includeArchived = searchParams.get("archived") === "1"; const view = parseView(searchParams.get("view")); const updateParam = useCallback( (key: string, value: string | null) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (value == null || value === "") { next.delete(key); } else { next.set(key, value); } return next; }, { replace: true }, ); }, [setSearchParams], ); const setQuery = (value: string) => updateParam("search", value || null); const setRepoFilter = (value: string) => updateParam("repo", value === "all" ? null : value); const setWorkflowFilter = (value: string) => updateParam("workflow", value === "all" ? null : value); const setCreatedFilter = (value: CreatedFilter) => updateParam("created", value === "all" ? null : value); const setIncludeArchived = (value: boolean) => updateParam("archived", value ? "1" : null); const setView = (value: ViewMode) => updateParam("view", value === "columns" ? null : value); const boardRuns = useBoardsRuns(includeArchived); const authConfig = useAuthConfig(); const systemInfo = useSystemInfo(); const isLandingReady = boardRuns.data !== undefined && authConfig.data !== undefined && systemInfo.data !== undefined; const initialColumns = useMemo( () => boardRuns.data ? buildBoardColumns(boardRuns.data) : buildSkeletonColumns(includeArchived), [boardRuns.data, includeArchived], ); const hasGitHubAuth = authConfig.data?.methods.includes("github") === true; const serverUrl = systemInfo.data?.server_url; const allRepos = [ ...new Set( initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))), ), ].sort(); const allWorkflows = [ ...new Set( initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.workflow))), ), ].sort(); const [columns, setColumns] = useState(initialColumns); const lowerQuery = query.toLowerCase(); useBoardEvents(); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; setColumns((prev) => prev.map((col) => { const oldIndex = col.items.findIndex((item) => item.id === active.id); const newIndex = col.items.findIndex((item) => item.id === over.id); if (oldIndex === -1 || newIndex === -1) return col; return { ...col, items: arrayMove(col.items, oldIndex, newIndex) }; }), ); }, []); const totalRuns = columns.reduce((sum, col) => sum + col.items.length, 0); const createdCutoffMs = createdCutoffMsFor(createdFilter); const filteredColumns = columns.map((col) => ({ ...col, items: col.items.filter( (item) => (repoFilter === "all" || item.repo === repoFilter) && (workflowFilter === "all" || item.workflow === workflowFilter) && (createdCutoffMs == null || (item.createdAt != null && Date.parse(item.createdAt) >= createdCutoffMs)) && (!query || item.title.toLowerCase().includes(lowerQuery) || item.repo.toLowerCase().includes(lowerQuery) || item.lifecycleStatusLabel?.toLowerCase().includes(lowerQuery) || (item.number != null && `#${item.number}`.includes(lowerQuery))), ), })); const filteredRuns = filteredColumns.reduce( (sum, col) => sum + col.items.length, 0, ); const visibleColumns = filteredColumns.filter( (col) => col.id !== "queued" || col.items.length > 0, ); return (
setQuery(e.target.value)} className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0" />
{view === "columns" ? ( <>
{visibleColumns.map((col) => (
))}
{isLandingReady && totalRuns === 0 ? ( ) : totalRuns > 0 && filteredRuns === 0 ? (
) : null} ) : ( <> {filteredRuns > 0 && (
{visibleColumns.flatMap((col) => col.items.map((item) => ( )), )}
)} {isLandingReady && totalRuns === 0 ? ( ) : totalRuns > 0 && filteredRuns === 0 ? (
) : null} )}
); }