diff --git a/README.md b/README.md index 2c7d4a54e..b530afc3a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This plugin adds a visual representation of Jenkins pipelines, showing each stag - [Collapse and expand individual stages](./docs/per-stage-collapse.md) with parallel branches or nested children - Quickly access details of each step and its results - Hide specific steps from view using the `hideFromView` Pipeline DSL step +- **Build Flow** - a zoomable, pannable DAG visualization with build history dots, inspired by the YABV plugin - Designed for better readability and faster troubleshooting ## Getting started @@ -32,14 +33,20 @@ Hidden steps are not displayed by default in the Pipeline Overview, but can be t ## Screenshots -Basic pipeline: +### Basic pipeline ![Different statuses](./docs/images/different-statuses.png) -Semi-complex pipeline: +### Semi-complex pipeline ![Semi complex pipeline](./docs/images/semi-complex-pipeline.png) +### Build Flow + +The Build Flow view provides a zoomable, pannable DAG visualization of upstream/downstream build relationships. See the [full documentation](./docs/build-flow.md) for details. + +![Build Flow Simple](./docs/images/build-flow-simple.png) + ## Video See a live demonstration from a Jenkins Contributor Summit: diff --git a/docs/build-flow.md b/docs/build-flow.md new file mode 100644 index 000000000..63811a0a9 --- /dev/null +++ b/docs/build-flow.md @@ -0,0 +1,24 @@ +# Build Flow + +The Build Flow view provides a zoomable, pannable DAG (directed acyclic graph) visualization of pipeline stages, inspired by the [Yet Another Build Visualizer](https://plugins.jenkins.io/yet-another-build-visualizer/) plugin. + +## Accessing Build Flow + +- On the build page Overview tab, the Build Flow card appears when upstream/downstream builds exist +- On the build page Stages tab, the Build Flow pane is embedded in the split view +- On the job page, the Build Flow widget shows the latest build's graph +- Users can toggle the job page widget via the gear icon on the Stages card (no admin access needed) + +## Capabilities + +- Zoom and pan with mouse wheel / trackpad; pinch-to-zoom on touch devices +- Center / zoom-to-fit controls +- Toggle between DAG and flat grid layouts +- Show/hide edge connections, stage labels, duration, and status badges +- Auto-refresh while the build is in progress +- Build history dots showing recent build results (click to navigate) +- Compact summary and widget views embedded in the build/job pages + +## Screenshot + +![Build Flow Simple](./images/build-flow-simple.png) diff --git a/docs/images/build-flow-simple.png b/docs/images/build-flow-simple.png new file mode 100644 index 000000000..91c84ce6c Binary files /dev/null and b/docs/images/build-flow-simple.png differ diff --git a/src/main/frontend/build-flow-view/app.tsx b/src/main/frontend/build-flow-view/app.tsx new file mode 100644 index 000000000..3c85b35cb --- /dev/null +++ b/src/main/frontend/build-flow-view/app.tsx @@ -0,0 +1,24 @@ +import { FunctionComponent } from "react"; + +import { + I18NProvider, + LocaleProvider, + ResourceBundleName, +} from "../common/i18n/index.ts"; +import { BuildFlow } from "./build-flow/main/BuildFlow.tsx"; +import { getRootElement } from "./build-flow/main/BuildFlowUtils.ts"; + +const App: FunctionComponent = () => { + const rootElement = getRootElement(); + const locale = rootElement?.dataset.userLocale ?? "en"; + + return ( + + + + + + ); +}; + +export default App; diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlow.scss b/src/main/frontend/build-flow-view/build-flow/main/BuildFlow.scss new file mode 100644 index 000000000..8472799cc --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlow.scss @@ -0,0 +1,412 @@ +@use "../../../common/styles/card-heading" as *; +@use "../../../common/styles/card-controls" as *; + +// On the overview page, make the card take at least half the viewport width +#pgv-build-flow-root[data-show-heading="true"] { + min-width: calc(50vw - 3rem); + // margin-bottom: 1rem; +} + +$icon-inline-offset: calc(1.125rem + 0.375rem); + +@mixin status-stripe($color, $bg: true) { + border-left-color: $color; + @if $bg { + background: color-mix(in srgb, $color 4%, var(--card-background)); + } +} + +.pgv-build-flow { + position: relative; + height: 350px; + overflow: hidden; + + // Full-page view: fill available space + &--full-page { + height: 100%; + min-height: 300px; + } + + &--expanded { + z-index: 30000; + position: fixed; + inset: 0; + background: var(--background); + border: none; + border-radius: 0; + height: unset; + overflow: visible; + + .pgv-build-flow__controls { + margin: 1rem; + color: var(--text-color); + } + + .jenkins-button svg { + width: 1.125rem; + height: 1.125rem; + } + } + + &__loading, + &__error, + &__empty { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 120px; + padding: 1rem; + color: var(--text-color-secondary); + font-size: var(--font-size-sm); + } + + &__error { + color: var(--error-color); + } + + &__svg { + display: block; + max-width: 100%; + } + + &__edge { + animation: pgv-edge-enter 0.3s ease-out backwards; + transition: opacity var(--standard-transition); + + &--in-progress path { + stroke-dasharray: 8 4; + animation: pgv-edge-pulse 1s linear infinite; + } + } + + &__truncated { + position: absolute; + bottom: 0.5rem; + left: 50%; + transform: translateX(-50%); + padding: 0.25rem 0.75rem; + border-radius: var(--form-input-border-radius, 4px); + background: var(--warning-bg-color, #fff3cd); + color: var(--warning-color, #856404); + font-size: var(--font-size-xs); + white-space: nowrap; + z-index: 5; + } + + &__node { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 0.5rem 0.625rem; + border: var(--card-border-width, 1px) solid var(--card-border-color); + border-left: 3px solid var(--text-color-secondary); + border-radius: var(--form-input-border-radius, 4px); + background: var(--card-background); + text-decoration: none; + color: var(--text-color); + transition: + border-color var(--standard-transition), + box-shadow var(--standard-transition), + background var(--standard-transition); + box-sizing: border-box; + cursor: pointer; + gap: 0.125rem; + overflow: hidden; + animation: pgv-node-enter 0.3s ease-out both; + + &:hover { + box-shadow: 0 2px 8px + color-mix(in srgb, var(--text-color) 12%, transparent); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 0.2rem var(--focus-input-border, var(--accent-color)); + } + + &:active { + box-shadow: 0 1px 4px + color-mix(in srgb, var(--text-color) 8%, transparent); + } + + &--current { + border-color: var(--accent-color); + border-left-color: var(--accent-color); + background: color-mix( + in srgb, + var(--accent-color) 6%, + var(--card-background) + ); + } + + &--success { + @include status-stripe(var(--success-color)); + } + + &--failure { + @include status-stripe(var(--error-color)); + } + + &--unstable { + @include status-stripe(var(--warning-color)); + } + + &--aborted { + @include status-stripe(var(--text-color-secondary), $bg: false); + } + + &--in-progress { + @include status-stripe(var(--build-color)); + } + + &--queued { + @include status-stripe(var(--text-color-secondary), $bg: false); + opacity: 0.7; + } + + &--not-built { + @include status-stripe(var(--text-color-secondary), $bg: false); + opacity: 0.7; + } + } + + &__node-header { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + } + + .pgv-status-icon { + width: 1.125rem; + height: 1.125rem; + } + + &__node-name { + font-weight: var(--font-bold-weight, 600); + font-size: var(--font-size-xs); + text-align: left; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + flex: 1; + min-width: 0; + } + + &__node-build-num { + font-size: var(--font-size-xs); + color: var(--text-color-secondary); + flex-shrink: 0; + white-space: nowrap; + } + + &__node-meta { + display: flex; + gap: 0.375rem; + font-size: var(--font-size-xs); + color: var(--text-color-secondary); + padding-left: $icon-inline-offset; + } + + &__node-desc { + font-size: 0.6875rem; + color: var(--text-color-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + padding-left: $icon-inline-offset; + } + + &__node-history { + display: flex; + gap: 2px; + margin-top: 2px; + padding-left: $icon-inline-offset; + } + + &__history-dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + cursor: pointer; + transition: + transform var(--standard-transition), + opacity var(--standard-transition); + text-decoration: none; + + &:hover { + transform: scale(1.5); + opacity: 0.85; + } + } + + // --- Controls (matches pgv-stages-graph__controls pattern) --- + &__controls { + position: absolute; + inset: 0.1875rem; + display: flex; + z-index: 1; + backdrop-filter: blur(10px); + border-radius: 0.45rem; + align-items: center; + color: var(--text-color-secondary); + background: color-mix( + in srgb, + var(--card-background, var(--background)) 80%, + transparent + ); + border: var(--jenkins-border-width, 1px) solid transparent; + background-clip: padding-box; + } + + &__heading { + @include card-heading; + } + + &__toggle-controls { + top: unset !important; + right: unset !important; + gap: 0; + } + + &__controls-bar { + top: unset !important; + right: unset !important; + left: unset !important; + gap: 0; + } + + &__controls-left { + display: flex; + align-items: center; + gap: 0; + } + + &__overflow-menu { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 200px; + max-height: min(320px, 50vh); + overflow-y: auto; + + .jenkins-button { + justify-content: flex-start; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + font-size: var(--font-size-xs); + width: 100%; + border-radius: 0.25rem; + + svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + } + } + } + + &__toggle--active { + color: var(--accent-color) !important; + } +} + +// Reuse same button styling as Stages card +.pgv-build-flow { + .pgw-fullscreen-controls, + .pgw-zoom-controls, + .pgv-build-flow__toggle-controls { + @include card-control-buttons; + } + + @include card-control-positions; +} + +// Fullscreen overlay +#page-body:has(.pgv-build-flow--expanded) { + z-index: 1001; +} + +// --- Reduced motion --- +@media (prefers-reduced-motion: reduce) { + .pgv-build-flow__node { + animation: none !important; + } + + .pgv-build-flow__edge { + animation: none !important; + } + + .pgv-build-flow__edge--in-progress path { + animation: none; + } +} + +// --- High contrast --- +@media (prefers-contrast: more) { + .pgv-build-flow__node { + background: var(--card-background) !important; + border-width: 2px !important; + border-left-width: 4px !important; + } + + .pgv-build-flow__edge path { + stroke-width: 3; + } + + .pgv-build-flow__history-dot { + border: 1px solid var(--text-color); + } +} + +// --- Forced colors (Windows High Contrast) --- +@media (forced-colors: active) { + .pgv-build-flow__node { + border: 2px solid ButtonText; + background: Canvas; + color: CanvasText; + + &--current { + border-color: Highlight; + } + } + + .pgv-build-flow__edge path { + stroke: ButtonText; + } + + .pgv-build-flow__history-dot { + forced-color-adjust: none; + } +} + +@keyframes pgv-node-enter { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pgv-edge-enter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes pgv-edge-pulse { + to { + stroke-dashoffset: -12; + } +} diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlow.tsx b/src/main/frontend/build-flow-view/build-flow/main/BuildFlow.tsx new file mode 100644 index 000000000..5294d1312 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlow.tsx @@ -0,0 +1,445 @@ +import "./BuildFlow.scss"; + +import { + FunctionComponent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ReactZoomPanPinchContentRef, + TransformComponent, + TransformWrapper, +} from "react-zoom-pan-pinch"; + +import Tooltip from "../../../common/components/tooltip.tsx"; +import { + LocalizedMessageKey, + useMessages, +} from "../../../common/i18n/index.ts"; +import { BuildFlowResponseModel } from "../model/BuildFlowModel.ts"; +import { + OverflowMenu, + PrimaryToggleControls, + ZoomControls, +} from "./BuildFlowControls.tsx"; +import { BuildFlowEdge } from "./BuildFlowEdge.tsx"; +import { IconChevronRight, IconClose, IconExpand } from "./BuildFlowIcons.tsx"; +import { + computeFlatLayout, + computeLayout, + computeNodeWidth, + NODE_WIDTH_MIN, +} from "./BuildFlowLayout.ts"; +import { BuildFlowNodeCard } from "./BuildFlowNodeCard.tsx"; +import { + type BuildFlowPreferences, + loadPreferences, + savePreferences, +} from "./BuildFlowPreferences.ts"; +import { + computeFullPath, + computeHighlightedIds, + getBaseUrl, + getRootUrl, + isFullPageContext, + shouldShowHeading, +} from "./BuildFlowUtils.ts"; + +// --- Component-specific constants --- +const MAX_SCALE = 3; +const CONTROLS_OVERHEAD = 80; // px reserved for zoom/toggle overlays +const MIN_CONTAINER_HEIGHT = 150; +const MAX_CONTAINER_HEIGHT = 350; + +// --- Main Component --- + +export interface BuildFlowProps { + /** Override the build URL (e.g. "job/foo/42/"). Falls back to DOM element data attribute. */ + buildUrl?: string; + /** Override the Jenkins root URL (e.g. "/jenkins"). Falls back to DOM element data attribute. */ + rootUrlOverride?: string; + /** Called when the graph layout is computed, reporting the ideal container height in px. */ + onNaturalHeight?: (height: number) => void; +} + +export const BuildFlow: FunctionComponent = ({ + buildUrl, + rootUrlOverride, + onNaturalHeight, +}) => { + const messages = useMessages(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [isExpanded, setIsExpanded] = useState(false); + const timerRef = useRef | null>(null); + const transformRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + const wrapperRef = useRef(null); + const isFirstMountRef = useRef(true); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + + // Track container size via ResizeObserver (like Stages) + useEffect(() => { + const element = wrapperRef.current; + if (!element) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + setContainerHeight(entry.contentRect.height); + } + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + // Load persisted preferences + const [preferences, setPreferences] = + useState(loadPreferences); + + const updatePreference = useCallback( + ( + key: K, + value: BuildFlowPreferences[K], + ) => { + setPreferences((prev) => { + const next = { ...prev, [key]: value }; + savePreferences(next); + return next; + }); + }, + [], + ); + + const baseUrl = buildUrl || getBaseUrl(); + const rootUrl = rootUrlOverride || getRootUrl(); + const showHeading = shouldShowHeading(); + + const nodeWidth = useMemo(() => { + if (!data || data.nodes.length === 0) return NODE_WIDTH_MIN; + return computeNodeWidth(data.nodes, preferences.showFullNames); + }, [data, preferences.showFullNames]); + + const fetchData = useCallback(async () => { + if (!baseUrl) { + setError("No build URL configured"); + setLoading(false); + return; + } + try { + const params = new URLSearchParams(); + if (!preferences.showUpstream) params.set("showUpstream", "false"); + if (!preferences.showDownstream) params.set("showDownstream", "false"); + const queryString = params.toString(); + const url = `${rootUrl}/${baseUrl}build-flow/api${queryString ? `?${queryString}` : ""}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const json = await response.json(); + setData(json.data as BuildFlowResponseModel); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Unknown error"); + } finally { + setLoading(false); + } + }, [baseUrl, rootUrl, preferences.showUpstream, preferences.showDownstream]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + useEffect(() => { + if (preferences.autoRefresh && data?.isAnyBuildOngoing) { + timerRef.current = setInterval(fetchData, 5000); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [preferences.autoRefresh, data?.isAnyBuildOngoing, fetchData]); + + const layout = useMemo(() => { + if (!data || data.nodes.length === 0) return null; + if (preferences.flattenGraph) + return computeFlatLayout(data.nodes, nodeWidth); + return computeLayout( + data.nodes, + data.edges, + preferences.layoutDirection, + nodeWidth, + ); + }, [data, preferences.layoutDirection, preferences.flattenGraph, nodeWidth]); + + // Compute initialScale: fit SVG into container minus overlay padding + const svgWidth = layout?.width ?? 0; + const svgHeight = layout?.height ?? 0; + + // Report natural height to parent for auto-sizing the pane + useEffect(() => { + if (onNaturalHeight && svgHeight > 0) { + onNaturalHeight(Math.min(svgHeight + CONTROLS_OVERHEAD, 500)); + } + }, [svgHeight, onNaturalHeight]); + + const initialScale = useMemo(() => { + if (!containerWidth || !containerHeight || !svgWidth || !svgHeight) { + return 1; + } + // Reserve space for heading overlay (top) and toggle controls (bottom) + const availWidth = containerWidth - 20; + const availHeight = containerHeight - 80; + if (availWidth <= 0 || availHeight <= 0) return 1; + return Math.min(1, availWidth / svgWidth, availHeight / svgHeight); + }, [containerWidth, containerHeight, svgWidth, svgHeight]); + const minScale = initialScale * 0.5; + + // Auto-fit when layout changes (initial load, toggle upstream/downstream, direction change) + const lastLayoutKey = useRef(""); + useEffect(() => { + const layoutKey = `${containerWidth}:${containerHeight}:${svgWidth}x${svgHeight}`; + if (layoutKey === lastLayoutKey.current) return; + lastLayoutKey.current = layoutKey; + if (!containerWidth || !containerHeight || !svgWidth || !svgHeight) return; + const ref = transformRef.current; + if (!ref) return; + const scale = Math.max(initialScale, 0.5); + // setTimeout ensures TransformWrapper is fully mounted and measured + const timer = setTimeout(() => { + ref.centerView(scale); + isFirstMountRef.current = false; + }, 50); + return () => clearTimeout(timer); + }, [containerWidth, containerHeight, svgWidth, svgHeight, initialScale]); + + // Focus current flow: full transitive path through the current build + const focusedPathIds = useMemo(() => { + if (!preferences.focusCurrentFlow || preferences.flattenGraph || !data) + return null; + const currentNode = data.nodes.find((n) => n.isCurrentBuild); + if (!currentNode) return null; + return computeFullPath(currentNode.id, data.edges); + }, [preferences.focusCurrentFlow, preferences.flattenGraph, data]); + + // Focus path: compute highlighted node set from hovered node + const highlightedIds = useMemo( + () => + data + ? computeHighlightedIds( + hoveredNodeId, + data.edges, + preferences.flattenGraph, + ) + : null, + [hoveredNodeId, data, preferences.flattenGraph], + ); + + // Clear hovered node when data changes (auto-refresh) + useEffect(() => { + setHoveredNodeId(null); + }, [data]); + + if (loading) { + return ( +
+ + {messages.format(LocalizedMessageKey.buildFlowLoading)} + +
+ ); + } + + if (error) { + return ( +
+ + {messages.format(LocalizedMessageKey.buildFlowError, { 0: error })} + +
+ ); + } + + if (!data || data.nodes.length === 0 || !layout) { + return ( +
+ + {messages.format(LocalizedMessageKey.buildFlowEmpty)} + +
+ ); + } + + const { layoutNodes, width, height } = layout; + const nodeById = new Map(layoutNodes.map((ln) => [ln.node.id, ln])); + const activeSet = highlightedIds ?? focusedPathIds; + + const isFullPage = isFullPageContext(); + const containerClasses = [ + "pgv-build-flow", + isExpanded && "pgv-build-flow--expanded", + isFullPage && "pgv-build-flow--full-page", + ] + .filter(Boolean) + .join(" "); + + // Dynamic height for job page: grow from small to max based on graph size. + // Skip when embedded via props (Stages tab) - parent controls height. + const isJobPageContext = + !buildUrl && !isFullPage && !showHeading && !isExpanded; + const dynamicHeight = + isJobPageContext && height > 0 + ? Math.min( + MAX_CONTAINER_HEIGHT, + Math.max(MIN_CONTAINER_HEIGHT, height + CONTROLS_OVERHEAD), + ) + : undefined; + + return ( +
+ {showHeading && !isExpanded && ( + + {messages.format(LocalizedMessageKey.buildFlowTitle)} + {IconChevronRight} + + )} + {(showHeading || isExpanded) && ( +
+ + + +
+ )} + + +
+
+ updatePreference("showUpstream", v)} + showDownstream={preferences.showDownstream} + setShowDownstream={(v) => updatePreference("showDownstream", v)} + showBuildHistory={preferences.showBuildHistory} + setShowBuildHistory={(v) => + updatePreference("showBuildHistory", v) + } + autoRefresh={preferences.autoRefresh} + setAutoRefresh={(v) => updatePreference("autoRefresh", v)} + focusCurrentFlow={preferences.focusCurrentFlow} + setFocusCurrentFlow={(v) => + updatePreference("focusCurrentFlow", v) + } + /> + updatePreference("layoutDirection", v)} + showDuration={preferences.showDuration} + setShowDuration={(v) => updatePreference("showDuration", v)} + showBuildNumber={preferences.showBuildNumber} + setShowBuildNumber={(v) => updatePreference("showBuildNumber", v)} + showFullNames={preferences.showFullNames} + setShowFullNames={(v) => updatePreference("showFullNames", v)} + showDescription={preferences.showDescription} + setShowDescription={(v) => updatePreference("showDescription", v)} + flattenGraph={preferences.flattenGraph} + setFlattenGraph={(v) => updatePreference("flattenGraph", v)} + /> +
+
+ + + {!preferences.flattenGraph && + data.edges.map((edge, i) => { + const from = nodeById.get(edge.from); + const to = nodeById.get(edge.to); + if (!from || !to) return null; + const edgeDimmed = + activeSet != null && + !(activeSet.has(edge.from) && activeSet.has(edge.to)); + return ( + ${edge.to}`} + from={from} + to={to} + sourceStatus={from.node.status} + direction={preferences.layoutDirection} + nodeWidth={nodeWidth} + isDimmed={edgeDimmed} + isFirstMount={isFirstMountRef.current} + index={i} + /> + ); + })} + {layoutNodes.map((ln, i) => ( + setHoveredNodeId(ln.node.id)} + onMouseLeave={() => setHoveredNodeId(null)} + /> + ))} + + +
+ {data.isTruncated && ( + + {messages.format(LocalizedMessageKey.buildFlowTruncated, { + 0: "200", + })} + + )} +
+ ); +}; diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowControls.tsx b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowControls.tsx new file mode 100644 index 000000000..5de682b29 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowControls.tsx @@ -0,0 +1,341 @@ +/** + * Controls components for the Build Flow view: zoom, primary toggles, overflow menu. + * Extracted from BuildFlow.tsx for maintainability. + */ +import Tippy from "@tippyjs/react"; +import { FunctionComponent, useCallback, useState } from "react"; +import { + ReactZoomPanPinchContextState, + useControls, + useTransformEffect, +} from "react-zoom-pan-pinch"; + +import { DefaultDropdownProps } from "../../../common/components/dropdown.tsx"; +import Tooltip from "../../../common/components/tooltip.tsx"; +import { + LocalizedMessageKey, + useMessages, +} from "../../../common/i18n/index.ts"; +import type { LayoutDirection } from "./BuildFlowLayout.ts"; +import { + IconBarChart, + IconChevronDown, + IconChevronUp, + IconDocumentText, + IconEllipsisVertical, + IconFitToView, + IconGitMerge, + IconGrid, + IconLocate, + IconMinus, + IconPlus, + IconPricetag, + IconRefreshCircle, + IconSwapHorizontal, + IconTime, + IconTrailSign, + ToggleButton, +} from "./BuildFlowIcons.tsx"; + +// --- Constants --- +const MAX_SCALE = 3; + +// --- Zoom Controls --- + +export function ZoomControls({ + initialScale, + minScale, +}: { + initialScale: number; + minScale: number; +}) { + const messages = useMessages(); + const { zoomIn, zoomOut, centerView } = useControls(); + const [scale, setScale] = useState(initialScale); + const [isTransformed, setIsTransformed] = useState(false); + const handleTransformEffect = useCallback( + (ref: ReactZoomPanPinchContextState) => { + setScale(ref.state.scale); + const state = ref.state; + const scaleDiff = Math.abs(state.scale - initialScale) > 0.01; + const positionDiff = + Math.abs(state.positionX) > 5 || Math.abs(state.positionY) > 5; + setIsTransformed(scaleDiff || positionDiff); + }, + [initialScale], + ); + useTransformEffect(handleTransformEffect); + + return ( +
+ + + + + + + + + +
+ ); +} + +// --- Toggle Controls --- + +interface ToggleControlsProps { + showUpstream: boolean; + setShowUpstream: (v: boolean) => void; + showDownstream: boolean; + setShowDownstream: (v: boolean) => void; + layoutDirection: LayoutDirection; + setLayoutDirection: (v: LayoutDirection) => void; + showDuration: boolean; + setShowDuration: (v: boolean) => void; + showBuildNumber: boolean; + setShowBuildNumber: (v: boolean) => void; + showFullNames: boolean; + setShowFullNames: (v: boolean) => void; + showDescription: boolean; + setShowDescription: (v: boolean) => void; + showBuildHistory: boolean; + setShowBuildHistory: (v: boolean) => void; + flattenGraph: boolean; + setFlattenGraph: (v: boolean) => void; + autoRefresh: boolean; + setAutoRefresh: (v: boolean) => void; + focusCurrentFlow: boolean; + setFocusCurrentFlow: (v: boolean) => void; +} + +/** Primary toggles: always visible in left group */ +export const PrimaryToggleControls: FunctionComponent< + Pick< + ToggleControlsProps, + | "showUpstream" + | "setShowUpstream" + | "showDownstream" + | "setShowDownstream" + | "showBuildHistory" + | "setShowBuildHistory" + | "autoRefresh" + | "setAutoRefresh" + | "focusCurrentFlow" + | "setFocusCurrentFlow" + > +> = ({ + showUpstream, + setShowUpstream, + showDownstream, + setShowDownstream, + showBuildHistory, + setShowBuildHistory, + autoRefresh, + setAutoRefresh, + focusCurrentFlow, + setFocusCurrentFlow, +}) => { + const messages = useMessages(); + return ( + <> + setShowUpstream(!showUpstream)} + tooltip={messages.format( + showUpstream + ? LocalizedMessageKey.buildFlowHideUpstream + : LocalizedMessageKey.buildFlowShowUpstream, + )} + /> + setShowDownstream(!showDownstream)} + tooltip={messages.format( + showDownstream + ? LocalizedMessageKey.buildFlowHideDownstream + : LocalizedMessageKey.buildFlowShowDownstream, + )} + /> + setFocusCurrentFlow(!focusCurrentFlow)} + tooltip={messages.format( + focusCurrentFlow + ? LocalizedMessageKey.buildFlowShowFullGraph + : LocalizedMessageKey.buildFlowFocusFlow, + )} + /> + setShowBuildHistory(!showBuildHistory)} + tooltip={messages.format( + showBuildHistory + ? LocalizedMessageKey.buildFlowHideHistory + : LocalizedMessageKey.buildFlowShowHistory, + )} + /> + setAutoRefresh(!autoRefresh)} + tooltip={messages.format( + autoRefresh + ? LocalizedMessageKey.buildFlowDisableAutoRefresh + : LocalizedMessageKey.buildFlowEnableAutoRefresh, + )} + /> + + ); +}; + +/** Secondary toggles: behind overflow menu */ +export const OverflowMenu: FunctionComponent< + Pick< + ToggleControlsProps, + | "layoutDirection" + | "setLayoutDirection" + | "showDuration" + | "setShowDuration" + | "showBuildNumber" + | "setShowBuildNumber" + | "showFullNames" + | "setShowFullNames" + | "showDescription" + | "setShowDescription" + | "flattenGraph" + | "setFlattenGraph" + > +> = ({ + layoutDirection, + setLayoutDirection, + showDuration, + setShowDuration, + showBuildNumber, + setShowBuildNumber, + showFullNames, + setShowFullNames, + showDescription, + setShowDescription, + flattenGraph, + setFlattenGraph, +}) => { + const messages = useMessages(); + const [menuOpen, setMenuOpen] = useState(false); + + return ( + setMenuOpen(false)} + {...DefaultDropdownProps} + placement="auto-start" + content={ +
+ setShowDuration(!showDuration)} + /> + setShowBuildNumber(!showBuildNumber)} + /> + setShowFullNames(!showFullNames)} + /> + setShowDescription(!showDescription)} + /> + + setFlattenGraph(!flattenGraph)} + /> +
+ } + > + +
+ ); +}; diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowEdge.tsx b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowEdge.tsx new file mode 100644 index 000000000..9f92ecc0d --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowEdge.tsx @@ -0,0 +1,77 @@ +import { FunctionComponent } from "react"; + +import { + type LayoutDirection, + type LayoutNode, + NODE_HEIGHT, +} from "./BuildFlowLayout.ts"; +import { statusColor } from "./BuildFlowUtils.ts"; + +export const BuildFlowEdge: FunctionComponent<{ + from: LayoutNode; + to: LayoutNode; + sourceStatus: string; + direction: LayoutDirection; + nodeWidth: number; + isDimmed: boolean; + isFirstMount: boolean; + index: number; +}> = ({ + from, + to, + sourceStatus, + direction, + nodeWidth, + isDimmed, + isFirstMount, + index, +}) => { + let x1: number, y1: number, x2: number, y2: number; + + if (direction === "LTR") { + x1 = from.x + nodeWidth; + y1 = from.y + NODE_HEIGHT / 2; + x2 = to.x; + y2 = to.y + NODE_HEIGHT / 2; + } else { + x1 = from.x + nodeWidth / 2; + y1 = from.y + NODE_HEIGHT; + x2 = to.x + nodeWidth / 2; + y2 = to.y; + } + + let path: string; + if (direction === "LTR") { + const offset = Math.max(30, Math.abs(x2 - x1) * 0.4); + path = `M ${x1} ${y1} C ${x1 + offset} ${y1}, ${x2 - offset} ${y2}, ${x2} ${y2}`; + } else { + const offset = Math.max(30, Math.abs(y2 - y1) * 0.4); + path = `M ${x1} ${y1} C ${x1} ${y1 + offset}, ${x2} ${y2 - offset}, ${x2} ${y2}`; + } + + const edgeClass = + sourceStatus === "IN_PROGRESS" + ? "pgv-build-flow__edge pgv-build-flow__edge--in-progress" + : "pgv-build-flow__edge"; + + return ( + + ); +}; diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowIcons.tsx b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowIcons.tsx new file mode 100644 index 000000000..b5ff94208 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowIcons.tsx @@ -0,0 +1,270 @@ +/** + * Shared SVG icon constants for the Build Flow view. + * Extracted to reduce inline duplication in BuildFlow.tsx. + */ +import { ReactNode } from "react"; + +import Tooltip from "../../../common/components/tooltip.tsx"; + +const ion = { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 512 512" }; +const s = { + fill: "none", + stroke: "currentColor", + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + strokeWidth: "32", +}; +const s48 = { ...s, strokeWidth: "48" }; +const s28 = { ...s, strokeWidth: "28" }; + +export const IconPlus = ( + + + +); + +export const IconMinus = ( + + + +); + +export const IconFitToView = ( + + + + +); + +export const IconChevronUp = ( + + + +); + +export const IconChevronDown = ( + + + +); + +export const IconChevronRight = ( + + + +); + +export const IconBarChart = ( + + + + + + +); + +export const IconRefreshCircle = ( + + + + + +); + +export const IconTime = ( + + + + +); + +export const IconPricetag = ( + + + + +); + +export const IconTrailSign = ( + + + +); + +export const IconDocumentText = ( + + + + +); + +export const IconSwapHorizontal = ( + + + +); + +export const IconGitMerge = ( + + + + + + + +); + +export const IconGrid = ( + + + + + + +); + +export const IconEllipsisVertical = ( + + + + + +); + +export const IconClose = ( + + + +); + +export const IconExpand = ( + + + +); + +export const IconLocate = ( + + + + + +); + +// --- Reusable toggle button helper --- + +export function ToggleButton({ + icon, + label, + active, + onToggle, + tooltip, + role = "menuitemcheckbox", +}: { + icon: ReactNode; + label: string; + active: boolean; + onToggle: () => void; + tooltip?: string; + role?: "menuitemcheckbox" | "menuitem"; +}) { + const btn = ( + + ); + return tooltip ? {btn} : btn; +} diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowLayout.spec.ts b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowLayout.spec.ts new file mode 100644 index 000000000..a67a012f5 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowLayout.spec.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; + +import { + BuildFlowEdgeModel, + BuildFlowNodeModel, +} from "../model/BuildFlowModel.ts"; +import { + COLUMN_GAP, + computeFlatLayout, + computeLayout, + computeNodeWidth, + formatDuration, + formatStatus, + NODE_HEIGHT, + NODE_WIDTH_MAX, + NODE_WIDTH_MIN, + PADDING, +} from "./BuildFlowLayout.ts"; + +function makeNode( + id: string, + jobName: string, + jobFullName?: string, +): BuildFlowNodeModel { + return { + id, + jobName, + jobFullName: jobFullName ?? jobName, + buildNumber: 1, + displayName: `${jobName} #1`, + url: `job/${jobName}/1/`, + status: "SUCCESS", + isCurrentBuild: false, + }; +} + +describe("formatDuration", () => { + it("formats milliseconds", () => { + expect(formatDuration(500)).toBe("500ms"); + expect(formatDuration(0)).toBe("0ms"); + }); + + it("formats seconds", () => { + expect(formatDuration(1000)).toBe("1s"); + expect(formatDuration(45000)).toBe("45s"); + }); + + it("formats minutes and seconds", () => { + expect(formatDuration(90000)).toBe("1m 30s"); + expect(formatDuration(120000)).toBe("2m"); + expect(formatDuration(3661000)).toBe("61m 1s"); + }); +}); + +describe("formatStatus", () => { + it("formats IN_PROGRESS", () => { + expect(formatStatus("IN_PROGRESS")).toBe("In Progress"); + }); + + it("formats NOT_BUILT", () => { + expect(formatStatus("NOT_BUILT")).toBe("Not Built"); + }); + + it("formats other statuses to title case", () => { + expect(formatStatus("SUCCESS")).toBe("Success"); + expect(formatStatus("FAILURE")).toBe("Failure"); + expect(formatStatus("UNSTABLE")).toBe("Unstable"); + expect(formatStatus("ABORTED")).toBe("Aborted"); + }); +}); + +describe("computeNodeWidth", () => { + it("returns minimum for short names", () => { + const nodes = [makeNode("a", "A"), makeNode("b", "B")]; + expect(computeNodeWidth(nodes, false)).toBe(NODE_WIDTH_MIN); + }); + + it("grows with longer names", () => { + const nodes = [makeNode("a", "my-very-long-service-name-here")]; + const width = computeNodeWidth(nodes, false); + expect(width).toBeGreaterThan(NODE_WIDTH_MIN); + expect(width).toBeLessThanOrEqual(NODE_WIDTH_MAX); + }); + + it("caps at max width", () => { + const longName = "a".repeat(100); + const nodes = [makeNode("a", longName)]; + expect(computeNodeWidth(nodes, false)).toBe(NODE_WIDTH_MAX); + }); + + it("uses full names when flag is set", () => { + const nodes = [makeNode("a", "svc", "org/team/svc")]; + const shortWidth = computeNodeWidth(nodes, false); + const fullWidth = computeNodeWidth(nodes, true); + expect(fullWidth).toBeGreaterThanOrEqual(shortWidth); + }); +}); + +describe("computeLayout", () => { + const nodeWidth = 160; + + it("returns empty for no nodes", () => { + const result = computeLayout([], [], "LTR", nodeWidth); + expect(result.layoutNodes).toHaveLength(0); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + }); + + it("lays out a single node", () => { + const nodes = [makeNode("a", "A")]; + const result = computeLayout(nodes, [], "LTR", nodeWidth); + expect(result.layoutNodes).toHaveLength(1); + expect(result.layoutNodes[0].col).toBe(0); + expect(result.layoutNodes[0].row).toBe(0); + expect(result.layoutNodes[0].x).toBe(PADDING); + expect(result.layoutNodes[0].y).toBe(PADDING); + expect(result.width).toBe(PADDING * 2 + nodeWidth); + expect(result.height).toBe(PADDING * 2 + NODE_HEIGHT); + }); + + it("assigns columns by longest path (linear chain)", () => { + const nodes = [makeNode("a", "A"), makeNode("b", "B"), makeNode("c", "C")]; + const edges: BuildFlowEdgeModel[] = [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + ]; + const result = computeLayout(nodes, edges, "LTR", nodeWidth); + expect(result.layoutNodes).toHaveLength(3); + + const colA = result.layoutNodes.find((n) => n.node.id === "a")!.col; + const colB = result.layoutNodes.find((n) => n.node.id === "b")!.col; + const colC = result.layoutNodes.find((n) => n.node.id === "c")!.col; + expect(colA).toBe(0); + expect(colB).toBe(1); + expect(colC).toBe(2); + }); + + it("places parallel nodes in the same column", () => { + const nodes = [makeNode("a", "A"), makeNode("b", "B"), makeNode("c", "C")]; + const edges: BuildFlowEdgeModel[] = [ + { from: "a", to: "b" }, + { from: "a", to: "c" }, + ]; + const result = computeLayout(nodes, edges, "LTR", nodeWidth); + + const colB = result.layoutNodes.find((n) => n.node.id === "b")!.col; + const colC = result.layoutNodes.find((n) => n.node.id === "c")!.col; + expect(colB).toBe(colC); + expect(colB).toBe(1); + }); + + it("LTR dimensions match expected formula", () => { + const nodes = [makeNode("a", "A"), makeNode("b", "B")]; + const edges: BuildFlowEdgeModel[] = [{ from: "a", to: "b" }]; + const result = computeLayout(nodes, edges, "LTR", nodeWidth); + + // 2 columns, 1 row + expect(result.width).toBe(PADDING * 2 + 2 * nodeWidth + 1 * COLUMN_GAP); + expect(result.height).toBe(PADDING * 2 + NODE_HEIGHT); + }); + + it("TTB swaps width and height axes", () => { + const nodes = [makeNode("a", "A"), makeNode("b", "B")]; + const edges: BuildFlowEdgeModel[] = [{ from: "a", to: "b" }]; + const result = computeLayout(nodes, edges, "TTB", nodeWidth); + + // 2 levels vertically (each NODE_HEIGHT + COLUMN_GAP apart) + expect(result.height).toBe(PADDING * 2 + 2 * NODE_HEIGHT + 1 * COLUMN_GAP); + expect(result.width).toBe(PADDING * 2 + nodeWidth); + }); + + it("centers parallel nodes vertically in LTR", () => { + const nodes = [ + makeNode("a", "Root"), + makeNode("b", "Branch1"), + makeNode("c", "Branch2"), + ]; + const edges: BuildFlowEdgeModel[] = [ + { from: "a", to: "b" }, + { from: "a", to: "c" }, + ]; + const result = computeLayout(nodes, edges, "LTR", nodeWidth); + + const nodeA = result.layoutNodes.find((n) => n.node.id === "a")!; + const nodeB = result.layoutNodes.find((n) => n.node.id === "b")!; + const nodeC = result.layoutNodes.find((n) => n.node.id === "c")!; + + // A should be centered between B and C vertically + const midBC = (nodeB.y + nodeC.y + NODE_HEIGHT) / 2; + const midA = nodeA.y + NODE_HEIGHT / 2; + expect(midA).toBeCloseTo(midBC, 0); + }); +}); + +describe("computeFlatLayout", () => { + const nodeWidth = 160; + + it("returns empty for no nodes", () => { + const result = computeFlatLayout([], nodeWidth); + expect(result.layoutNodes).toHaveLength(0); + expect(result.width).toBe(0); + }); + + it("lays out nodes in a grid", () => { + const nodes = [makeNode("a", "A"), makeNode("b", "B"), makeNode("c", "C")]; + const result = computeFlatLayout(nodes, nodeWidth); + expect(result.layoutNodes).toHaveLength(3); + // All in same row (800 / (160+16) = 4 cols fits) + expect(result.layoutNodes[0].row).toBe(0); + expect(result.layoutNodes[1].row).toBe(0); + expect(result.layoutNodes[2].row).toBe(0); + }); + + it("wraps to next row when too many nodes", () => { + const nodes = Array.from({ length: 10 }, (_, i) => + makeNode(`n${i}`, `N${i}`), + ); + const result = computeFlatLayout(nodes, nodeWidth); + // cols = floor(800/(160+16)) = 4 + const lastNode = result.layoutNodes[result.layoutNodes.length - 1]; + expect(lastNode.row).toBeGreaterThan(0); + }); + + it("height increases with more rows", () => { + const fewNodes = [makeNode("a", "A"), makeNode("b", "B")]; + const manyNodes = Array.from({ length: 20 }, (_, i) => + makeNode(`n${i}`, `N${i}`), + ); + const fewResult = computeFlatLayout(fewNodes, nodeWidth); + const manyResult = computeFlatLayout(manyNodes, nodeWidth); + expect(manyResult.height).toBeGreaterThan(fewResult.height); + }); +}); diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowLayout.ts b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowLayout.ts new file mode 100644 index 000000000..667a3ac59 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowLayout.ts @@ -0,0 +1,193 @@ +/** + * Pure layout computation and formatting utilities for the Build Flow view. + * Extracted for unit testing. + */ + +import { + BuildFlowEdgeModel, + BuildFlowNodeModel, +} from "../model/BuildFlowModel.ts"; + +// --- Layout constants --- +export const NODE_WIDTH_MIN = 140; +export const NODE_WIDTH_MAX = 320; +export const NODE_HEIGHT = 72; +export const COLUMN_GAP = 60; +const ROW_GAP = 24; +export const PADDING = 12; +const CHAR_WIDTH_APPROX = 7; +const FLAT_CONTAINER_WIDTH = 800; +const FLAT_COL_GAP = 16; +const FLAT_ROW_GAP = 12; + +export type LayoutDirection = "LTR" | "TTB"; + +export interface LayoutNode { + node: BuildFlowNodeModel; + col: number; + row: number; + x: number; + y: number; +} + +interface LayoutResult { + layoutNodes: LayoutNode[]; + width: number; + height: number; +} + +/** Compute node width from the longest displayed name */ +export function computeNodeWidth( + nodes: BuildFlowNodeModel[], + showFullNames: boolean, +): number { + let maxLength = 0; + for (const n of nodes) { + const name = showFullNames ? n.jobFullName : n.jobName; + if (name.length > maxLength) maxLength = name.length; + } + const computed = maxLength * CHAR_WIDTH_APPROX + 32; + return Math.min(NODE_WIDTH_MAX, Math.max(NODE_WIDTH_MIN, computed)); +} + +/** Flat grid layout - no edges, just cards in rows */ +export function computeFlatLayout( + nodes: BuildFlowNodeModel[], + nodeWidth: number, +): LayoutResult { + if (nodes.length === 0) return { layoutNodes: [], width: 0, height: 0 }; + const cols = Math.max( + 1, + Math.floor(FLAT_CONTAINER_WIDTH / (nodeWidth + FLAT_COL_GAP)), + ); + const layoutNodes: LayoutNode[] = nodes.map((node, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + return { + node, + col, + row, + x: PADDING + col * (nodeWidth + FLAT_COL_GAP), + y: PADDING + row * (NODE_HEIGHT + FLAT_ROW_GAP), + }; + }); + const totalRows = Math.ceil(nodes.length / cols); + return { + layoutNodes, + width: PADDING * 2 + cols * nodeWidth + (cols - 1) * FLAT_COL_GAP, + height: + PADDING * 2 + totalRows * NODE_HEIGHT + (totalRows - 1) * FLAT_ROW_GAP, + }; +} + +/** DAG layout using longest-path column assignment */ +export function computeLayout( + nodes: BuildFlowNodeModel[], + edges: BuildFlowEdgeModel[], + direction: LayoutDirection, + nodeWidth: number, +): LayoutResult { + if (nodes.length === 0) return { layoutNodes: [], width: 0, height: 0 }; + + const nodeMap = new Map(); + for (const n of nodes) nodeMap.set(n.id, n); + + const children = new Map(); + const parents = new Map(); + for (const n of nodes) { + children.set(n.id, []); + parents.set(n.id, []); + } + for (const e of edges) { + children.get(e.from)?.push(e.to); + parents.get(e.to)?.push(e.from); + } + + const columnAssignment = new Map(); + const visited = new Set(); + + function assignColumn(id: string): number { + if (columnAssignment.has(id)) return columnAssignment.get(id)!; + if (visited.has(id)) return 0; + visited.add(id); + const parentIds = parents.get(id) || []; + const maxParentColumn = + parentIds.length === 0 + ? -1 + : Math.max(...parentIds.map((p) => assignColumn(p))); + const column = maxParentColumn + 1; + columnAssignment.set(id, column); + return column; + } + + for (const n of nodes) assignColumn(n.id); + + const columns = new Map(); + for (const n of nodes) { + const c = columnAssignment.get(n.id)!; + if (!columns.has(c)) columns.set(c, []); + columns.get(c)!.push(n.id); + } + + const maxColumn = Math.max(...columns.keys()); + const maxRows = Math.max(...[...columns.values()].map((ids) => ids.length)); + + const layoutNodes: LayoutNode[] = []; + for (let c = 0; c <= maxColumn; c++) { + const ids = columns.get(c) || []; + const count = ids.length; + ids.forEach((id, row) => { + let x: number, y: number; + if (direction === "LTR") { + const levelHeight = count * NODE_HEIGHT + (count - 1) * ROW_GAP; + const totalHeight = maxRows * NODE_HEIGHT + (maxRows - 1) * ROW_GAP; + const yOffset = (totalHeight - levelHeight) / 2; + x = PADDING + c * (nodeWidth + COLUMN_GAP); + y = PADDING + yOffset + row * (NODE_HEIGHT + ROW_GAP); + } else { + const levelWidth = count * nodeWidth + (count - 1) * COLUMN_GAP; + const totalWidth = maxRows * nodeWidth + (maxRows - 1) * COLUMN_GAP; + const xOffset = (totalWidth - levelWidth) / 2; + x = PADDING + xOffset + row * (nodeWidth + COLUMN_GAP); + y = PADDING + c * (NODE_HEIGHT + COLUMN_GAP); + } + layoutNodes.push({ node: nodeMap.get(id)!, col: c, row, x, y }); + }); + } + + let width: number, height: number; + if (direction === "LTR") { + width = PADDING * 2 + (maxColumn + 1) * nodeWidth + maxColumn * COLUMN_GAP; + height = PADDING * 2 + maxRows * NODE_HEIGHT + (maxRows - 1) * ROW_GAP; + } else { + width = PADDING * 2 + maxRows * nodeWidth + (maxRows - 1) * COLUMN_GAP; + height = + PADDING * 2 + (maxColumn + 1) * NODE_HEIGHT + maxColumn * COLUMN_GAP; + } + + return { layoutNodes, width, height }; +} + +// --- Formatting --- + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${minutes}m`; +} + +export function formatStatus(status: string): string { + switch (status) { + case "IN_PROGRESS": + return "In Progress"; + case "NOT_BUILT": + return "Not Built"; + default: + return status.charAt(0) + status.slice(1).toLowerCase(); + } +} diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowNodeCard.tsx b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowNodeCard.tsx new file mode 100644 index 000000000..83ec5af08 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowNodeCard.tsx @@ -0,0 +1,156 @@ +import { FunctionComponent, useEffect, useState } from "react"; + +import StatusIcon from "../../../common/components/status-icon.tsx"; +import Tooltip from "../../../common/components/tooltip.tsx"; +import { BuildFlowNodeModel } from "../model/BuildFlowModel.ts"; +import { + formatDuration, + formatStatus, + type LayoutNode, + NODE_HEIGHT, +} from "./BuildFlowLayout.ts"; +import { resultDotColor, statusClass, toResult } from "./BuildFlowUtils.ts"; + +// --- Live Duration --- + +const LiveDuration: FunctionComponent<{ startTimeMs: number }> = ({ + startTimeMs, +}) => { + const [elapsed, setElapsed] = useState(() => Date.now() - startTimeMs); + useEffect(() => { + const id = setInterval(() => setElapsed(Date.now() - startTimeMs), 1000); + return () => clearInterval(id); + }, [startTimeMs]); + return {formatDuration(elapsed)}; +}; + +// --- Build History Dots --- + +const BuildHistoryDots: FunctionComponent<{ + node: BuildFlowNodeModel; + rootUrl: string; +}> = ({ node, rootUrl }) => { + const history = [...node.recentResults!].reverse(); + return ( + + {history.map((r, i) => { + const buildNumber = node.buildNumber - (history.length - 1 - i); + return ( + + e.stopPropagation()} + /> + + ); + })} + + ); +}; + +// --- Node Card --- + +export const BuildFlowNodeCard: FunctionComponent<{ + layout: LayoutNode; + rootUrl: string; + nodeWidth: number; + showDuration: boolean; + showBuildNumber: boolean; + showFullNames: boolean; + showDescription: boolean; + showBuildHistory: boolean; + index: number; + isFirstMount: boolean; + isDimmed: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; +}> = ({ + layout, + rootUrl, + nodeWidth, + showDuration, + showBuildNumber, + showFullNames, + showDescription, + showBuildHistory, + index, + isFirstMount, + isDimmed, + onMouseEnter, + onMouseLeave, +}) => { + const { node, x, y } = layout; + const classes = [ + "pgv-build-flow__node", + statusClass(node.status), + node.isCurrentBuild ? "pgv-build-flow__node--current" : "", + ] + .filter(Boolean) + .join(" "); + + const href = node.url ? `${rootUrl}/${node.url}` : undefined; + const name = showFullNames ? node.jobFullName : node.jobName; + const ariaLabel = `${node.jobName} #${node.buildNumber} - ${formatStatus(node.status)}`; + + return ( + + +
+ + {name} + {showBuildNumber && ( + + #{node.buildNumber} + + )} +
+ + {showDuration && node.durationMs != null && ( + {formatDuration(node.durationMs)} + )} + {showDuration && + node.status === "IN_PROGRESS" && + node.startTimeMs != null && ( + + )} + + {showDescription && node.description && ( + {node.description} + )} + {showBuildHistory && + node.recentResults && + node.recentResults.length > 0 && ( + + )} +
+ + ); +}; diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowPreferences.ts b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowPreferences.ts new file mode 100644 index 000000000..c8e360316 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowPreferences.ts @@ -0,0 +1,47 @@ +const STORAGE_KEY = "pgv-build-flow-preferences"; + +export interface BuildFlowPreferences { + showUpstream: boolean; + showDownstream: boolean; + layoutDirection: "LTR" | "TTB"; + showDuration: boolean; + showBuildNumber: boolean; + showFullNames: boolean; + showDescription: boolean; + showBuildHistory: boolean; + flattenGraph: boolean; + autoRefresh: boolean; + focusCurrentFlow: boolean; +} + +const DEFAULT_PREFERENCES: BuildFlowPreferences = { + showUpstream: true, + showDownstream: true, + layoutDirection: "LTR", + showDuration: true, + showBuildNumber: true, + showFullNames: false, + showDescription: false, + showBuildHistory: false, + flattenGraph: false, + autoRefresh: true, + focusCurrentFlow: false, +}; + +export function loadPreferences(): BuildFlowPreferences { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { ...DEFAULT_PREFERENCES, ...JSON.parse(raw) }; + } catch { + // ignore + } + return DEFAULT_PREFERENCES; +} + +export function savePreferences(preferences: BuildFlowPreferences): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } catch { + // ignore + } +} diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowScss.spec.ts b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowScss.spec.ts new file mode 100644 index 000000000..ba7e5e369 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowScss.spec.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { describe, expect, it } from "vitest"; + +describe("BuildFlow.scss", () => { + const scssPath = resolve(__dirname, "BuildFlow.scss"); + const scss = readFileSync(scssPath, "utf-8"); + + it("contains all required CSS custom properties", () => { + const requiredTokens = [ + "--card-background", + "--card-border-width", + "--card-border-color", + "--form-input-border-radius", + "--text-color", + "--text-color-secondary", + "--success-color", + "--error-color", + "--warning-color", + "--build-color", + "--accent-color", + "--font-size-xs", + "--font-bold-weight", + "--standard-transition", + ]; + for (const token of requiredTokens) { + expect(scss).toContain(token); + } + }); + + it("includes prefers-reduced-motion media query", () => { + expect(scss).toContain("prefers-reduced-motion"); + }); + + it("includes prefers-contrast media query", () => { + expect(scss).toContain("prefers-contrast"); + }); + + it("includes forced-colors media query", () => { + expect(scss).toContain("forced-colors"); + }); + + it("contains BEM-structured class selectors", () => { + const requiredClasses = [ + "__node", + "__edge", + "__node-header", + "__node-name", + "pgv-status-icon", + "__controls-bar", + "__overflow-menu", + "__history-dot", + ]; + for (const cls of requiredClasses) { + expect(scss).toContain(cls); + } + }); + + it("uses pgv-node-enter animation", () => { + expect(scss).toContain("@keyframes pgv-node-enter"); + }); + + it("uses pgv-edge-pulse animation for in-progress edges", () => { + expect(scss).toContain("@keyframes pgv-edge-pulse"); + }); +}); diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowUtils.spec.ts b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowUtils.spec.ts new file mode 100644 index 000000000..0b770cd0e --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowUtils.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + resultDotColor, + statusClass, + statusColor, + toResult, +} from "./BuildFlowUtils.ts"; + +describe("statusClass", () => { + it("maps known statuses", () => { + expect(statusClass("SUCCESS")).toBe("pgv-build-flow__node--success"); + expect(statusClass("FAILURE")).toBe("pgv-build-flow__node--failure"); + expect(statusClass("UNSTABLE")).toBe("pgv-build-flow__node--unstable"); + expect(statusClass("ABORTED")).toBe("pgv-build-flow__node--aborted"); + expect(statusClass("IN_PROGRESS")).toBe( + "pgv-build-flow__node--in-progress", + ); + expect(statusClass("QUEUED")).toBe("pgv-build-flow__node--queued"); + }); + + it("defaults to not-built for unknown statuses", () => { + expect(statusClass("UNKNOWN")).toBe("pgv-build-flow__node--not-built"); + expect(statusClass("")).toBe("pgv-build-flow__node--not-built"); + }); +}); + +describe("statusColor", () => { + it("maps known statuses to CSS vars", () => { + expect(statusColor("SUCCESS")).toBe("var(--success-color)"); + expect(statusColor("FAILURE")).toBe("var(--error-color)"); + expect(statusColor("UNSTABLE")).toBe("var(--warning-color)"); + expect(statusColor("IN_PROGRESS")).toBe("var(--build-color)"); + }); + + it("defaults to secondary text color", () => { + expect(statusColor("ABORTED")).toBe("var(--text-color-secondary)"); + expect(statusColor("NOT_BUILT")).toBe("var(--text-color-secondary)"); + }); +}); + +describe("resultDotColor", () => { + it("maps known results to colors", () => { + expect(resultDotColor("SUCCESS")).toBe("var(--success-color)"); + expect(resultDotColor("FAILURE")).toBe("var(--error-color)"); + expect(resultDotColor("UNSTABLE")).toBe("var(--warning-color)"); + }); + + it("defaults to secondary text color for aborted/unknown", () => { + expect(resultDotColor("ABORTED")).toBe("var(--text-color-secondary)"); + expect(resultDotColor("")).toBe("var(--text-color-secondary)"); + }); +}); + +describe("toResult", () => { + it("maps Build Flow statuses to Result enum values", () => { + expect(toResult("SUCCESS")).toBe("success"); + expect(toResult("FAILURE")).toBe("failure"); + expect(toResult("UNSTABLE")).toBe("unstable"); + expect(toResult("ABORTED")).toBe("aborted"); + expect(toResult("IN_PROGRESS")).toBe("running"); + expect(toResult("QUEUED")).toBe("queued"); + expect(toResult("NOT_BUILT")).toBe("not_built"); + }); + + it("defaults to unknown for unrecognized statuses", () => { + expect(toResult("SOMETHING_ELSE")).toBe("unknown"); + expect(toResult("")).toBe("unknown"); + }); +}); diff --git a/src/main/frontend/build-flow-view/build-flow/main/BuildFlowUtils.ts b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowUtils.ts new file mode 100644 index 000000000..587bacbdb --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/BuildFlowUtils.ts @@ -0,0 +1,150 @@ +/** + * Shared utility functions for the Build Flow view. + * Extracted from BuildFlow.tsx for testability and DRY. + */ + +import { Result } from "../../../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx"; + +// --- DOM context --- + +const ELEMENT_IDS = [ + "pgv-build-flow-root", + "pgv-build-flow-summary", + "pgv-build-flow-job", +] as const; + +function getElement(): HTMLElement | null { + for (const id of ELEMENT_IDS) { + const element = document.getElementById(id); + if (element) return element; + } + return null; +} + +/** Returns the first matching mount-point element for the Build Flow view. */ +export function getRootElement(): HTMLElement | null { + return getElement(); +} + +export function getBaseUrl(): string { + return getElement()?.dataset.buildUrl || ""; +} + +export function getRootUrl(): string { + return getElement()?.dataset.rootUrl || ""; +} + +export function shouldShowHeading(): boolean { + return getElement()?.dataset.showHeading === "true"; +} + +export function isFullPageContext(): boolean { + return getElement()?.dataset.fullPage === "true"; +} + +// --- Status mapping --- + +const STATUS_CLASSES: Record = { + SUCCESS: "pgv-build-flow__node--success", + FAILURE: "pgv-build-flow__node--failure", + UNSTABLE: "pgv-build-flow__node--unstable", + ABORTED: "pgv-build-flow__node--aborted", + IN_PROGRESS: "pgv-build-flow__node--in-progress", + QUEUED: "pgv-build-flow__node--queued", +}; + +export function statusClass(status: string): string { + return STATUS_CLASSES[status] || "pgv-build-flow__node--not-built"; +} + +const STATUS_TO_RESULT: Record = { + SUCCESS: Result.success, + FAILURE: Result.failure, + UNSTABLE: Result.unstable, + ABORTED: Result.aborted, + IN_PROGRESS: Result.running, + QUEUED: Result.queued, + NOT_BUILT: Result.not_built, +}; + +export function toResult(status: string): Result { + return STATUS_TO_RESULT[status] || Result.unknown; +} + +const STATUS_COLORS: Record = { + SUCCESS: "var(--success-color)", + FAILURE: "var(--error-color)", + UNSTABLE: "var(--warning-color)", + IN_PROGRESS: "var(--build-color)", +}; + +export function statusColor(status: string): string { + return STATUS_COLORS[status] || "var(--text-color-secondary)"; +} + +export function resultDotColor(result: string): string { + // Same palette as statusColor, but IN_PROGRESS gets the default (secondary) + return STATUS_COLORS[result] || "var(--text-color-secondary)"; +} + +// --- Focus path (transitive closure) --- + +interface Edge { + from: string; + to: string; +} + +/** + * Computes the full upstream + downstream path through a given node. + * Returns a Set of all node IDs reachable from `nodeId` by traversing + * edges in both directions (BFS). + */ +export function computeFullPath(nodeId: string, edges: Edge[]): Set { + // Build adjacency lists for both directions + const downstream = new Map(); + const upstream = new Map(); + for (const e of edges) { + if (!downstream.has(e.from)) downstream.set(e.from, []); + downstream.get(e.from)!.push(e.to); + if (!upstream.has(e.to)) upstream.set(e.to, []); + upstream.get(e.to)!.push(e.from); + } + + const result = new Set([nodeId]); + + function bfs(adjacencyMap: Map) { + const queue: string[] = [nodeId]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const next of adjacencyMap.get(current) ?? []) { + if (!result.has(next)) { + result.add(next); + queue.push(next); + } + } + } + } + + bfs(downstream); + bfs(upstream); + + return result; +} + +/** + * Computes the set of node IDs that should be highlighted when hovering a node. + * Returns null if no node is hovered or the graph is flattened (no edges shown). + */ +export function computeHighlightedIds( + hoveredNodeId: string | null, + edges: Edge[], + flattenGraph: boolean, +): Set | null { + if (!hoveredNodeId || flattenGraph) return null; + const set = new Set([hoveredNodeId]); + for (const edge of edges) { + if (edge.from === hoveredNodeId) set.add(edge.to); + if (edge.to === hoveredNodeId) set.add(edge.from); + } + return set; +} diff --git a/src/main/frontend/build-flow-view/build-flow/main/FocusPath.spec.ts b/src/main/frontend/build-flow-view/build-flow/main/FocusPath.spec.ts new file mode 100644 index 000000000..a0dea00d5 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/main/FocusPath.spec.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { BuildFlowEdgeModel } from "../model/BuildFlowModel"; +import { computeFullPath, computeHighlightedIds } from "./BuildFlowUtils"; + +describe("Focus path highlighting", () => { + const edges: BuildFlowEdgeModel[] = [ + { from: "A", to: "B" }, + { from: "B", to: "C" }, + { from: "A", to: "D" }, + ]; + + it("computes highlighted set from hovered node", () => { + const result = computeHighlightedIds("B", edges, false); + expect(result).not.toBeNull(); + // B's upstream (A) and downstream (C) + expect(result!.has("A")).toBe(true); + expect(result!.has("B")).toBe(true); + expect(result!.has("C")).toBe(true); + // D is not adjacent to B + expect(result!.has("D")).toBe(false); + }); + + it("includes all neighbors for a root node", () => { + const result = computeHighlightedIds("A", edges, false); + expect(result).not.toBeNull(); + expect(result!.has("A")).toBe(true); + expect(result!.has("B")).toBe(true); + expect(result!.has("D")).toBe(true); + // C is not directly connected to A + expect(result!.has("C")).toBe(false); + }); + + it("returns null when no node is hovered", () => { + expect(computeHighlightedIds(null, edges, false)).toBeNull(); + }); + + it("returns null in flat layout mode", () => { + expect(computeHighlightedIds("B", edges, true)).toBeNull(); + }); + + it("handles leaf node with single edge", () => { + const result = computeHighlightedIds("C", edges, false); + expect(result).not.toBeNull(); + expect(result!.size).toBe(2); // C and B + expect(result!.has("C")).toBe(true); + expect(result!.has("B")).toBe(true); + }); + + it("handles isolated node with no edges", () => { + const result = computeHighlightedIds("Z", edges, false); + expect(result).not.toBeNull(); + expect(result!.size).toBe(1); + expect(result!.has("Z")).toBe(true); + }); + + it("determines dimmed state for nodes", () => { + const highlighted = computeHighlightedIds("B", edges, false); + // D is dimmed (not in highlighted set) + expect(highlighted != null && !highlighted.has("D")).toBe(true); + // B is not dimmed + expect(highlighted != null && !highlighted.has("B")).toBe(false); + }); + + it("determines dimmed state for edges", () => { + const highlighted = computeHighlightedIds("B", edges, false); + // Edge A->B: both endpoints highlighted -> not dimmed + const edgeAB = + highlighted != null && !(highlighted.has("A") && highlighted.has("B")); + expect(edgeAB).toBe(false); + + // Edge A->D: D is not highlighted -> dimmed + const edgeAD = + highlighted != null && !(highlighted.has("A") && highlighted.has("D")); + expect(edgeAD).toBe(true); + }); +}); + +describe("computeFullPath - transitive focus", () => { + // Linear chain: A -> B -> C -> D + const linearEdges: BuildFlowEdgeModel[] = [ + { from: "A", to: "B" }, + { from: "B", to: "C" }, + { from: "C", to: "D" }, + ]; + + it("traverses the full chain from a middle node", () => { + const result = computeFullPath("B", linearEdges); + expect(result.has("A")).toBe(true); + expect(result.has("B")).toBe(true); + expect(result.has("C")).toBe(true); + expect(result.has("D")).toBe(true); + expect(result.size).toBe(4); + }); + + it("traverses upstream from a leaf node", () => { + const result = computeFullPath("D", linearEdges); + expect(result.size).toBe(4); + expect(result.has("A")).toBe(true); + }); + + it("traverses downstream from a root node", () => { + const result = computeFullPath("A", linearEdges); + expect(result.size).toBe(4); + expect(result.has("D")).toBe(true); + }); + + // Diamond: A -> B, A -> C, B -> D, C -> D + const diamondEdges: BuildFlowEdgeModel[] = [ + { from: "A", to: "B" }, + { from: "A", to: "C" }, + { from: "B", to: "D" }, + { from: "C", to: "D" }, + ]; + + it("includes all paths through a diamond from the root", () => { + const result = computeFullPath("A", diamondEdges); + expect(result.size).toBe(4); + }); + + it("includes only the flow through a branch node, not parallel branches", () => { + const result = computeFullPath("B", diamondEdges); + // B's flow: A -> B -> D (C is a parallel branch not through B) + expect(result.size).toBe(3); + expect(result.has("A")).toBe(true); + expect(result.has("B")).toBe(true); + expect(result.has("D")).toBe(true); + expect(result.has("C")).toBe(false); + }); + + // Disconnected: A -> B, C -> D (two separate chains) + const disconnectedEdges: BuildFlowEdgeModel[] = [ + { from: "A", to: "B" }, + { from: "C", to: "D" }, + ]; + + it("only includes the connected component", () => { + const result = computeFullPath("A", disconnectedEdges); + expect(result.size).toBe(2); + expect(result.has("A")).toBe(true); + expect(result.has("B")).toBe(true); + expect(result.has("C")).toBe(false); + expect(result.has("D")).toBe(false); + }); + + it("handles an isolated node with no edges", () => { + const result = computeFullPath("Z", disconnectedEdges); + expect(result.size).toBe(1); + expect(result.has("Z")).toBe(true); + }); +}); diff --git a/src/main/frontend/build-flow-view/build-flow/model/BuildFlowModel.ts b/src/main/frontend/build-flow-view/build-flow/model/BuildFlowModel.ts new file mode 100644 index 000000000..761600503 --- /dev/null +++ b/src/main/frontend/build-flow-view/build-flow/model/BuildFlowModel.ts @@ -0,0 +1,33 @@ +export interface BuildFlowNodeModel { + id: string; + jobName: string; + jobFullName: string; + buildNumber: number; + displayName: string; + url: string; + status: + | "SUCCESS" + | "FAILURE" + | "UNSTABLE" + | "ABORTED" + | "IN_PROGRESS" + | "NOT_BUILT" + | "QUEUED"; + durationMs?: number | null; + startTimeMs?: number | null; + description?: string | null; + isCurrentBuild: boolean; + recentResults?: string[] | null; +} + +export interface BuildFlowEdgeModel { + from: string; + to: string; +} + +export interface BuildFlowResponseModel { + nodes: BuildFlowNodeModel[]; + edges: BuildFlowEdgeModel[]; + isAnyBuildOngoing: boolean; + isTruncated?: boolean; +} diff --git a/src/main/frontend/build-flow-view/index.tsx b/src/main/frontend/build-flow-view/index.tsx new file mode 100644 index 000000000..7b90ea49b --- /dev/null +++ b/src/main/frontend/build-flow-view/index.tsx @@ -0,0 +1,59 @@ +import { createRoot, Root } from "react-dom/client"; + +import { + BUILD_FLOW_CHANGED_EVENT, + BUILD_FLOW_LS_KEY, +} from "../common/user/user-preferences-provider.tsx"; +import App from "./app.tsx"; +import { getRootElement } from "./build-flow/main/BuildFlowUtils.ts"; + +const rootElement = getRootElement(); +// The build overview tab sets data-show-heading; job page views don't. +// User toggle only gates job page rendering. +const isBuildPage = rootElement?.dataset.showHeading != null; + +function getCardContainer(el: HTMLElement): HTMLElement { + return (el.closest(".jenkins-card") as HTMLElement) ?? el; +} + +let reactRoot: Root | null = null; + +function mount() { + if (!rootElement || reactRoot) return; + const card = getCardContainer(rootElement); + card.style.display = ""; + rootElement.style.display = ""; + reactRoot = createRoot(rootElement); + reactRoot.render(); +} + +function unmount() { + if (!rootElement) return; + if (reactRoot) { + reactRoot.unmount(); + reactRoot = null; + } + const card = getCardContainer(rootElement); + card.style.display = "none"; +} + +if (rootElement) { + const showBuildFlow = + window.localStorage.getItem(BUILD_FLOW_LS_KEY) !== "false"; + + if (isBuildPage || showBuildFlow) { + mount(); + } else { + unmount(); + } + + if (!isBuildPage) { + document.addEventListener(BUILD_FLOW_CHANGED_EVENT, ((e: CustomEvent) => { + if (e.detail) { + mount(); + } else { + unmount(); + } + }) as EventListener); + } +} diff --git a/src/main/frontend/common/i18n/messages.ts b/src/main/frontend/common/i18n/messages.ts index 07ac0517d..1cacfd51e 100644 --- a/src/main/frontend/common/i18n/messages.ts +++ b/src/main/frontend/common/i18n/messages.ts @@ -78,9 +78,41 @@ export enum LocalizedMessageKey { settings = "settings", showNames = "settings.showStageName", showDuration = "settings.showStageDuration", + showBuildFlow = "settings.showBuildFlow", consoleNewTab = "console.newTab", tailLogsResume = "tailLogs.resume", tailLogsPause = "tailLogs.pause", + buildFlowTitle = "buildFlow.title", + buildFlowLoading = "buildFlow.loading", + buildFlowError = "buildFlow.error", + buildFlowEmpty = "buildFlow.empty", + buildFlowTruncated = "buildFlow.truncated", + buildFlowZoomIn = "buildFlow.zoomIn", + buildFlowZoomOut = "buildFlow.zoomOut", + buildFlowFitToView = "buildFlow.fitToView", + buildFlowShowUpstream = "buildFlow.showUpstream", + buildFlowHideUpstream = "buildFlow.hideUpstream", + buildFlowShowDownstream = "buildFlow.showDownstream", + buildFlowHideDownstream = "buildFlow.hideDownstream", + buildFlowFocusFlow = "buildFlow.focusFlow", + buildFlowShowFullGraph = "buildFlow.showFullGraph", + buildFlowShowHistory = "buildFlow.showHistory", + buildFlowHideHistory = "buildFlow.hideHistory", + buildFlowEnableAutoRefresh = "buildFlow.enableAutoRefresh", + buildFlowDisableAutoRefresh = "buildFlow.disableAutoRefresh", + buildFlowShowDuration = "buildFlow.showDuration", + buildFlowShowBuildNumber = "buildFlow.showBuildNumber", + buildFlowShowFullNames = "buildFlow.showFullNames", + buildFlowShowDescription = "buildFlow.showDescription", + buildFlowTopToBottom = "buildFlow.topToBottom", + buildFlowLeftToRight = "buildFlow.leftToRight", + buildFlowShowGraph = "buildFlow.showGraph", + buildFlowFlattenToGrid = "buildFlow.flattenToGrid", + buildFlowMoreOptions = "buildFlow.moreOptions", + buildFlowDisplayOptions = "buildFlow.displayOptions", + buildFlowClose = "buildFlow.close", + buildFlowExpand = "buildFlow.expand", + buildFlowGraphLabel = "buildFlow.graphLabel", expandNestedStages = "collapse.expandNested", collapseNestedStages = "collapse.collapseNested", expandAllStages = "collapse.expandAll", @@ -97,9 +129,43 @@ const DEFAULT_MESSAGES: ResourceBundle = { [LocalizedMessageKey.settings]: "Settings", [LocalizedMessageKey.showNames]: "Show stage names", [LocalizedMessageKey.showDuration]: "Show stage duration", + [LocalizedMessageKey.showBuildFlow]: "Show Build Flow", [LocalizedMessageKey.consoleNewTab]: "View step as plain text", [LocalizedMessageKey.tailLogsResume]: "Resume tailing logs", [LocalizedMessageKey.tailLogsPause]: "Pause tailing logs", + [LocalizedMessageKey.buildFlowTitle]: "Build Flow", + [LocalizedMessageKey.buildFlowLoading]: "Loading build flow\u2026", + [LocalizedMessageKey.buildFlowError]: "Failed to load build flow: {0}", + [LocalizedMessageKey.buildFlowEmpty]: + "No upstream or downstream builds found.", + [LocalizedMessageKey.buildFlowTruncated]: + "Graph truncated at {0} nodes. Some builds may not be shown.", + [LocalizedMessageKey.buildFlowZoomIn]: "Zoom in", + [LocalizedMessageKey.buildFlowZoomOut]: "Zoom out", + [LocalizedMessageKey.buildFlowFitToView]: "Fit to view", + [LocalizedMessageKey.buildFlowShowUpstream]: "Show upstream", + [LocalizedMessageKey.buildFlowHideUpstream]: "Hide upstream", + [LocalizedMessageKey.buildFlowShowDownstream]: "Show downstream", + [LocalizedMessageKey.buildFlowHideDownstream]: "Hide downstream", + [LocalizedMessageKey.buildFlowFocusFlow]: "Focus current build's flow", + [LocalizedMessageKey.buildFlowShowFullGraph]: "Show full graph", + [LocalizedMessageKey.buildFlowShowHistory]: "Show build history", + [LocalizedMessageKey.buildFlowHideHistory]: "Hide build history", + [LocalizedMessageKey.buildFlowEnableAutoRefresh]: "Enable auto-refresh", + [LocalizedMessageKey.buildFlowDisableAutoRefresh]: "Disable auto-refresh", + [LocalizedMessageKey.buildFlowShowDuration]: "Show duration", + [LocalizedMessageKey.buildFlowShowBuildNumber]: "Show build number", + [LocalizedMessageKey.buildFlowShowFullNames]: "Show full names", + [LocalizedMessageKey.buildFlowShowDescription]: "Show description", + [LocalizedMessageKey.buildFlowTopToBottom]: "Top-to-bottom", + [LocalizedMessageKey.buildFlowLeftToRight]: "Left-to-right", + [LocalizedMessageKey.buildFlowShowGraph]: "Show graph", + [LocalizedMessageKey.buildFlowFlattenToGrid]: "Flatten to grid", + [LocalizedMessageKey.buildFlowMoreOptions]: "More options", + [LocalizedMessageKey.buildFlowDisplayOptions]: "Build flow display options", + [LocalizedMessageKey.buildFlowClose]: "Close", + [LocalizedMessageKey.buildFlowExpand]: "Expand", + [LocalizedMessageKey.buildFlowGraphLabel]: "Build flow graph with {0} builds", [LocalizedMessageKey.expandNestedStages]: "Expand nested stages", [LocalizedMessageKey.collapseNestedStages]: "Collapse nested stages", [LocalizedMessageKey.expandAllStages]: "Expand all stages", diff --git a/src/main/frontend/common/styles/_card-controls.scss b/src/main/frontend/common/styles/_card-controls.scss new file mode 100644 index 000000000..076316ad6 --- /dev/null +++ b/src/main/frontend/common/styles/_card-controls.scss @@ -0,0 +1,46 @@ +/// Shared button styling for zoom/fullscreen/toggle control bars. +/// Apply inside the container selector that wraps .pgw-zoom-controls, etc. +@mixin card-control-buttons { + .jenkins-button { + --button-background: transparent; + --button-background--hover: transparent; + --button-background--active: transparent; + --button-box-shadow--focus: transparent; + + min-height: 2rem; + padding: 0.5rem; + border-radius: 0.35rem; + color: inherit !important; + + &::before { + border: none; + } + + svg { + width: 1rem; + height: 1rem; + transition: color var(--standard-transition); + } + + &:not(:disabled) { + &:hover, + &:active { + svg { + color: var(--text-color); + } + } + } + } +} + +@mixin card-control-positions { + .pgw-fullscreen-controls { + left: unset !important; + bottom: unset !important; + } + + .pgw-zoom-controls { + left: unset !important; + top: unset !important; + } +} diff --git a/src/main/frontend/common/styles/_card-heading.scss b/src/main/frontend/common/styles/_card-heading.scss new file mode 100644 index 000000000..6cceaad28 --- /dev/null +++ b/src/main/frontend/common/styles/_card-heading.scss @@ -0,0 +1,29 @@ +/// Shared heading-link style used by card controls (stages, build-flow). +/// Apply with `@include card-heading;` inside a BEM `&__heading` selector. +@mixin card-heading { + padding: 0 0.55rem; + bottom: unset !important; + right: unset !important; + line-height: 2rem; + font-weight: var(--font-bold-weight); + font-size: var(--font-size-sm); + gap: 0.375rem; + color: var(--text-color-secondary); + text-decoration: none !important; + + &[href] { + color: var(--text-color) !important; + } + + svg { + width: 0.875rem; + height: 0.875rem; + color: var(--text-color-secondary); + transition: color var(--standard-transition); + margin-right: -0.25rem; + } + + &:hover svg { + color: var(--text-color); + } +} diff --git a/src/main/frontend/common/user/user-preferences-provider.tsx b/src/main/frontend/common/user/user-preferences-provider.tsx index edb9b5d74..220201c85 100644 --- a/src/main/frontend/common/user/user-preferences-provider.tsx +++ b/src/main/frontend/common/user/user-preferences-provider.tsx @@ -5,11 +5,14 @@ interface PipelineGraphViewPreferences { setShowNames: (val: boolean) => void; showDurations: boolean; setShowDurations: (val: boolean) => void; + showBuildFlow: boolean; + setShowBuildFlow: (val: boolean) => void; } const defaultPreferences = { showNames: false, showDurations: false, + showBuildFlow: true, }; const UserPreferencesContext = createContext< @@ -18,6 +21,9 @@ const UserPreferencesContext = createContext< const makeKey = (setting: string) => `pgv-graph-view.${setting}`; +export const BUILD_FLOW_LS_KEY = makeKey("showBuildFlow"); +export const BUILD_FLOW_CHANGED_EVENT = "pgv-show-build-flow-changed"; + const parsePreferenceValue = ( value: string | undefined, fallback: T, @@ -75,6 +81,9 @@ export const UserPreferencesProvider = ({ ), ), ); + const [showBuildFlow, setShowBuildFlow] = useState( + loadFromLocalStorage(BUILD_FLOW_LS_KEY, defaultPreferences.showBuildFlow), + ); const persistShowNames = (val: boolean) => { window.localStorage.setItem(stageNamesKey, String(val)); @@ -86,6 +95,14 @@ export const UserPreferencesProvider = ({ setShowDurations(val); }; + const persistShowBuildFlow = (val: boolean) => { + window.localStorage.setItem(BUILD_FLOW_LS_KEY, String(val)); + setShowBuildFlow(val); + document.dispatchEvent( + new CustomEvent(BUILD_FLOW_CHANGED_EVENT, { detail: val }), + ); + }; + return ( {children} diff --git a/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.spec.tsx b/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.spec.tsx index 89f4a3d72..e5e1ac6c3 100644 --- a/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.spec.tsx +++ b/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.spec.tsx @@ -36,6 +36,7 @@ describe("SettingsButton", () => { expect(screen.getByText("Show stage names")).toBeInTheDocument(); expect(screen.getByText("Show stage duration")).toBeInTheDocument(); + expect(screen.getByText("Show Build Flow")).toBeInTheDocument(); }); it("should update preferences when toggling checkboxes", () => { @@ -52,5 +53,10 @@ describe("SettingsButton", () => { const showDurationsCheckbox = screen.getByLabelText("Show stage duration"); fireEvent.click(showDurationsCheckbox); expect(showDurationsCheckbox).toBeChecked(); + + const showBuildFlowCheckbox = screen.getByLabelText("Show Build Flow"); + expect(showBuildFlowCheckbox).toBeChecked(); + fireEvent.click(showBuildFlowCheckbox); + expect(showBuildFlowCheckbox).not.toBeChecked(); }); }); diff --git a/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.tsx b/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.tsx index 1ab67049a..1baa45ba4 100644 --- a/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.tsx +++ b/src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/settings-button.tsx @@ -16,8 +16,14 @@ type SettingsButtonProps = { export default function SettingsButton({ buttonPortal }: SettingsButtonProps) { const messages = useMessages(); - const { showNames, setShowNames, showDurations, setShowDurations } = - useUserPreferences(); + const { + showNames, + setShowNames, + showDurations, + setShowDurations, + showBuildFlow, + setShowBuildFlow, + } = useUserPreferences(); return ( <> @@ -38,6 +44,11 @@ export default function SettingsButton({ buttonPortal }: SettingsButtonProps) { value={showDurations} setValue={setShowDurations} /> + , ]} /> diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.spec.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.spec.tsx index dd74abc26..e7d969790 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.spec.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.spec.tsx @@ -44,6 +44,8 @@ vi.mock("../../../common/user/user-preferences-provider.tsx", () => ({ setShowNames: () => {}, showDurations: true, setShowDurations: () => {}, + showBuildFlow: true, + setShowBuildFlow: () => {}, }), })); @@ -103,7 +105,7 @@ beforeEach(() => { (useLayoutPreferences as any).mockReturnValue({ stageViewPosition: "top", - mainViewVisibility: "both", + mainViewVisibility: "all", setStageViewPosition: () => {}, setMainViewVisibility: () => {}, stageViewWidth: 0, diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.tsx index 840fdc0f2..7c6c8097d 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/PipelineConsole.tsx @@ -2,6 +2,7 @@ import "./pipeline-console.scss"; import "../../../pipeline-graph-view/app.scss"; import "../../../pipeline-graph-view/pipeline-graph/styles/main.scss"; +import { BuildFlow } from "../../../build-flow-view/build-flow/main/BuildFlow.tsx"; import Dropdown from "../../../common/components/dropdown.tsx"; import DropdownPortal from "../../../common/components/dropdown-portal.tsx"; import { @@ -27,8 +28,12 @@ export default function PipelineConsole() { const rootElement = document.getElementById("console-pipeline-root"); const currentRunPath = rootElement?.dataset.currentRunPath!; const previousRunPath = rootElement?.dataset.previousRunPath; + const hasBuildFlow = rootElement?.dataset.hasBuildFlow === "true"; + const rootUrl = rootElement?.dataset.rootUrl || ""; + const buildUrl = rootElement?.dataset.buildUrl || ""; - const { stageViewPosition, mainViewVisibility } = useLayoutPreferences(); + const { stageViewPosition, mainViewVisibility, setBuildFlowHeight } = + useLayoutPreferences(); const { complete, tailLogs, @@ -56,6 +61,85 @@ export default function PipelineConsole() { const { canConfigure } = useUserPermissions(); + const showGraph = + mainViewVisibility === "all" || + mainViewVisibility === "graphAndStages" || + mainViewVisibility === "graphOnly"; + const showStages = + mainViewVisibility === "all" || + mainViewVisibility === "graphAndStages" || + mainViewVisibility === "stagesOnly"; + const showBuildFlowPane = + hasBuildFlow && mainViewVisibility === "all" && !isOnlyPlaceholderNode; + + const stagesContent = ( + + {showStages && !isOnlyPlaceholderNode && ( +
+ {loading ? ( +
+ + +
+ ) : ( + + )} +
+ )} + +
+ {loading ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); + + const graphContent = ( + + {!isOnlyPlaceholderNode && + showGraph && + (loading ? ( + + ) : ( + + ))} + + {stagesContent} + + ); + return ( <> + ) : ( <> ), @@ -97,75 +184,35 @@ export default function PipelineConsole() { /> - {showSplitView && ( - - {!isOnlyPlaceholderNode && - (mainViewVisibility === "both" || - mainViewVisibility === "graphOnly") && - (loading ? ( - - ) : ( - - ))} - - - {(mainViewVisibility === "both" || - mainViewVisibility === "stagesOnly") && - !isOnlyPlaceholderNode && ( -
- {loading ? ( -
- - -
- ) : ( - - )} -
- )} - -
- {loading ? ( -
- - -
- ) : ( - + )} + + {showSplitView && mainViewVisibility !== "buildFlowOnly" && ( + <> + {showBuildFlowPane ? ( + +
+ - )} -
-
- +
+ + {graphContent} +
+ ) : ( + graphContent + )} + )} {!loading && onlyQueuedPlaceholder && ( diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.spec.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.spec.tsx index 1a71a748c..10c464903 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.spec.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.spec.tsx @@ -17,16 +17,18 @@ const { vi.mock("../providers/user-preference-provider.tsx", () => ({ useLayoutPreferences: mockUseLayoutPreferences.mockReturnValue({ - mainViewVisibility: "both", + mainViewVisibility: "graphAndStages", setMainViewVisibility: mockSetMainViewVisibility, stageViewPosition: "top", setStageViewPosition: mockSetStageViewPosition, isMobile: false, }), MainViewVisibility: { - BOTH: "both", + GRAPH_AND_STAGES: "graphAndStages", + ALL: "all", GRAPH_ONLY: "graphOnly", STAGES_ONLY: "stagesOnly", + BUILD_FLOW_ONLY: "buildFlowOnly", }, StageViewPosition: { TOP: "top", @@ -45,7 +47,7 @@ describe("StagesCustomization", () => { it("should show current values", () => { render(); - expect(screen.getAllByText("Graph and stages").length).toBeGreaterThan(0); + expect(screen.getAllByText("Graph and Stages").length).toBeGreaterThan(0); expect(screen.getAllByText("Top").length).toBeGreaterThan(0); }); @@ -71,7 +73,7 @@ describe("StagesCustomization", () => { it("should return null on mobile", () => { mockUseLayoutPreferences.mockReturnValueOnce({ - mainViewVisibility: "both", + mainViewVisibility: "all", setMainViewVisibility: vi.fn(), stageViewPosition: "top", setStageViewPosition: vi.fn(), diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.tsx index e56bd47c1..4189ec035 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages-customization.tsx @@ -8,7 +8,11 @@ import { useLayoutPreferences, } from "../providers/user-preference-provider.tsx"; -export default function StagesCustomization() { +export default function StagesCustomization({ + showBuildFlow = true, +}: { + showBuildFlow?: boolean; +}) { const { mainViewVisibility, setMainViewVisibility, @@ -43,18 +47,35 @@ export default function StagesCustomization() { Views - {mainViewVisibility === MainViewVisibility.BOTH && "Graph and stages"} + {mainViewVisibility === MainViewVisibility.GRAPH_AND_STAGES && + "Graph and Stages"} + {mainViewVisibility === MainViewVisibility.ALL && + "Graph, Stages and Build Flow"} {mainViewVisibility === MainViewVisibility.GRAPH_ONLY && "Graph"} {mainViewVisibility === MainViewVisibility.STAGES_ONLY && "Stages"} + {mainViewVisibility === MainViewVisibility.BUILD_FLOW_ONLY && + "Build Flow"} @@ -120,7 +141,10 @@ export default function StagesCustomization() { id="stage-view-position" value={stageViewPosition} onChange={handlePositionChange} - disabled={mainViewVisibility === MainViewVisibility.STAGES_ONLY} + disabled={ + mainViewVisibility === MainViewVisibility.STAGES_ONLY || + mainViewVisibility === MainViewVisibility.BUILD_FLOW_ONLY + } > @@ -138,7 +162,7 @@ function ViewIcon({ mainViewVisibility: MainViewVisibility; stageViewPosition: StageViewPosition; }) { - if (mainViewVisibility === "both" && stageViewPosition === "top") { + if (mainViewVisibility === "all" && stageViewPosition === "top") { return ( void; setMainViewVisibility: (visibility: MainViewVisibility) => void; setTreeViewWidth: (width: number) => void; setStageViewWidth: (width: number) => void; setStageViewHeight: (height: number) => void; + setBuildFlowHeight: (height: number) => void; + setBuildFlowCollapsed: (collapsed: boolean) => void; /** * Returns true if the current window width is less than the mobile breakpoint. * Used for disabling customization options in favor of a mobile-friendly layout. @@ -51,6 +57,7 @@ const LS_KEYS = { treeViewWidth: "layout.treeViewWidth", stageViewWidth: "layout.stageViewWidth", stageViewHeight: "layout.stageViewHeight", + buildFlowCollapsed: "layout.buildFlowCollapsed", }; // HELPER @@ -62,6 +69,9 @@ const loadFromLocalStorage = (key: string, fallback: T): T => { if (typeof fallback === "number") { return Number(value) as T; } + if (typeof fallback === "boolean") { + return (value === "true") as unknown as T; + } return value as unknown as T; } } catch (e) { @@ -88,7 +98,7 @@ export const LayoutPreferencesProvider = ({ ); const [persistedMainViewVisibility, setMainViewVisibilityState] = useState( - loadFromLocalStorage(LS_KEYS.mainViewVisibility, MainViewVisibility.BOTH), + loadFromLocalStorage(LS_KEYS.mainViewVisibility, MainViewVisibility.ALL), ); const [treeViewWidth, setTreeViewWidthState] = useState( @@ -100,6 +110,10 @@ export const LayoutPreferencesProvider = ({ const [stageViewHeight, setStageViewHeightState] = useState( loadFromLocalStorage(LS_KEYS.stageViewHeight, 250), ); + const [buildFlowHeight, setBuildFlowHeightState] = useState(0); + const [buildFlowCollapsed, setBuildFlowCollapsedState] = useState( + loadFromLocalStorage(LS_KEYS.buildFlowCollapsed, false), + ); // Handle responsive override const isMobile = windowWidth < MOBILE_BREAKPOINT; @@ -150,6 +164,13 @@ export const LayoutPreferencesProvider = ({ ); }, [stageViewHeight]); + useEffect(() => { + window.localStorage.setItem( + LS_KEYS.buildFlowCollapsed, + buildFlowCollapsed.toString(), + ); + }, [buildFlowCollapsed]); + // Update window width on resize useEffect(() => { const handleResize = () => setWindowWidth(window.innerWidth); @@ -165,6 +186,10 @@ export const LayoutPreferencesProvider = ({ const setStageViewWidth = (width: number) => setStageViewWidthState(width); const setStageViewHeight = (height: number) => setStageViewHeightState(height); + const setBuildFlowHeight = (height: number) => + setBuildFlowHeightState(height); + const setBuildFlowCollapsed = (collapsed: boolean) => + setBuildFlowCollapsedState(collapsed); return ( diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.scss b/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.scss index 3c6b6e2ae..a885e21bd 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.scss +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.scss @@ -15,6 +15,8 @@ position: relative; display: flex; flex-direction: column; + min-height: 0; + min-width: 0; &--vertical { display: grid; @@ -90,3 +92,48 @@ } } } + +.pgv-split-view__collapse-toggle { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: var(--card-background); + border: var(--card-border-width) solid var(--card-border-color); + border-radius: var(--form-input-border-radius); + color: var(--text-color); + font-size: var(--font-size-xs); + font-weight: 500; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--item-background--hover); + } + + &--inline { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 2; + border: none; + background: none; + padding: 0.25rem 0.5rem; + opacity: 0.7; + + &:hover { + opacity: 1; + background: var(--item-background--hover); + } + } +} + +.pgv-split-view__collapse-chevron { + transition: transform 0.2s ease; + transform: rotate(90deg); + flex-shrink: 0; + + &--collapsed { + transform: rotate(0deg); + } +} diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.tsx index c5e941bfc..b94f4503b 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/split-view.tsx @@ -17,39 +17,50 @@ export default function SplitView(props: SplitViewNewProps) { setTreeViewWidth, setStageViewWidth, setStageViewHeight, + setBuildFlowHeight, treeViewWidth, stageViewWidth, stageViewHeight, + buildFlowHeight, + buildFlowCollapsed, + setBuildFlowCollapsed, } = useLayoutPreferences(); - const { direction = "horizontal", storageKey } = props; + const { + direction = "horizontal", + storageKey, + collapsible, + collapseLabel, + } = props; const [isDragging, setIsDragging] = useState(false); const isVertical = direction === "vertical"; + const isCollapsed = collapsible && buildFlowCollapsed; - const initialSize = (() => { + const getSizeForKey = () => { if (storageKey === "stages") return treeViewWidth; if (storageKey === "graph") { return isVertical ? stageViewHeight : stageViewWidth; } - return 300; // fallback - })(); - - useEffect(() => { - const newSize = (() => { - if (storageKey === "stages") return treeViewWidth; - if (storageKey === "graph") { - return direction === "vertical" ? stageViewHeight : stageViewWidth; - } - return 300; - })(); + if (storageKey === "buildFlow") return buildFlowHeight; + return 300; + }; - setPanelSize(newSize); - }, [direction, treeViewWidth, stageViewWidth, stageViewHeight, storageKey]); + const [panelSize, setPanelSize] = useState(getSizeForKey); - const [panelSize, setPanelSize] = useState(initialSize); + useEffect(() => { + setPanelSize(getSizeForKey()); + }, [ + direction, + treeViewWidth, + stageViewWidth, + stageViewHeight, + buildFlowHeight, + storageKey, + ]); const dividerRef = useRef(null); + const containerRef = useRef(null); const startDragging = (e: ReactMouseEvent) => { setIsDragging(true); @@ -58,61 +69,53 @@ export default function SplitView(props: SplitViewNewProps) { const stopDragging = () => setIsDragging(false); + const getContainerOffset = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + return isVertical ? rect.top : rect.left; + } + return 0; + }; + + const persistSize = (size: number) => { + if (storageKey === "stages") { + setTreeViewWidth(size); + } else if (storageKey === "graph") { + if (isVertical) { + setStageViewHeight(size); + } else { + setStageViewWidth(size); + } + } else if (storageKey === "buildFlow") { + setBuildFlowHeight(size); + } + }; + const handleDragging = (e: MouseEvent) => { if (!isDragging) return; - // Dynamically follow mouse based on direction - const newSize = - direction === "vertical" - ? e.clientY - getContainerOffset() - : e.clientX - getContainerOffset(); + const newSize = isVertical + ? e.clientY - getContainerOffset() + : e.clientX - getContainerOffset(); const clampedSize = Math.max( - direction === "vertical" ? 100 : 200, + isVertical ? 100 : 200, Math.min(newSize, 1500), ); setPanelSize(clampedSize); - - // Update context sizes - if (storageKey === "stages") { - setTreeViewWidth(clampedSize); - } else if (storageKey === "graph") { - if (direction === "vertical") { - setStageViewHeight(clampedSize); - } else { - setStageViewWidth(clampedSize); - } - } + persistSize(clampedSize); }; const handleDoubleClick = () => { const resetSize = (() => { if (storageKey === "stages") return 300; if (storageKey === "graph") return isVertical ? 250 : 600; + if (storageKey === "buildFlow") return 0; return 300; })(); setPanelSize(resetSize); - - if (storageKey === "stages") { - setTreeViewWidth(resetSize); - } else if (storageKey === "graph") { - if (direction === "vertical") { - setStageViewHeight(resetSize); - } else { - setStageViewWidth(resetSize); - } - } - }; - - const containerRef = useRef(null); - - const getContainerOffset = () => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - return direction === "vertical" ? rect.top : rect.left; - } - return 0; + persistSize(resetSize); }; useEffect(() => { @@ -124,14 +127,32 @@ export default function SplitView(props: SplitViewNewProps) { }; }); - // If we only have one child, just return it const childrenArray = Children.toArray(props.children).filter(Boolean); if (childrenArray.length === 1) { return <>{childrenArray[0]}; } + if (isCollapsed) { + return ( +
+ + {childrenArray[1]} +
+ ); + } + const gridTemplate = - direction === "vertical" ? `${panelSize}px 1fr` : `${panelSize}px 1fr`; + storageKey === "buildFlow" && panelSize === 0 + ? "1fr 1fr" + : `${panelSize}px 1fr`; return (
+ {collapsible && ( + + )} {childrenArray[0]}
{childrenArray[1]}
@@ -164,8 +195,28 @@ export default function SplitView(props: SplitViewNewProps) { ); } +const ChevronIcon = ({ collapsed }: { collapsed?: boolean }) => ( + + + +); + interface SplitViewNewProps { children: ReactNode[]; direction?: "horizontal" | "vertical"; - storageKey: "stages" | "graph"; + storageKey: "stages" | "graph" | "buildFlow"; + /** Enable collapse/expand toggle on the first child pane */ + collapsible?: boolean; + /** Label shown in the collapse toggle button */ + collapseLabel?: string; } diff --git a/src/main/frontend/pipeline-graph-view/app.scss b/src/main/frontend/pipeline-graph-view/app.scss index 8e55c9dc7..0a0952877 100644 --- a/src/main/frontend/pipeline-graph-view/app.scss +++ b/src/main/frontend/pipeline-graph-view/app.scss @@ -1,3 +1,5 @@ +@use "../common/styles/card-controls" as *; + .app-page-body--one-column { margin: unset; max-width: unset; @@ -5,36 +7,7 @@ .pgw-fullscreen-controls, .pgw-zoom-controls { - .jenkins-button { - --button-background: transparent; - --button-background--hover: transparent; - --button-background--active: transparent; - --button-box-shadow--focus: transparent; - - min-height: 2rem; - padding: 0.5rem; - border-radius: 0.35rem; - color: inherit !important; - - &::before { - border: none; - } - - svg { - width: 1rem; - height: 1rem; - transition: color var(--standard-transition); - } - - &:not(:disabled) { - &:hover, - &:active { - svg { - color: var(--text-color); - } - } - } - } + @include card-control-buttons; } .pgw-fullscreen-controls { diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration.java index 8e74dd407..7a2cbca07 100644 --- a/src/main/java/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration.java +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration.java @@ -17,6 +17,7 @@ public class PipelineGraphViewConfiguration extends GlobalConfiguration { private boolean showStageNames; private boolean showStageDurations; private boolean showGraphOnBuildPage; + private boolean showBuildFlowOnJobPage = true; public PipelineGraphViewConfiguration() { load(); @@ -62,6 +63,16 @@ public void setShowGraphOnBuildPage(boolean showGraphOnBuildPage) { save(); } + public boolean isShowBuildFlowOnJobPage() { + return showBuildFlowOnJobPage; + } + + @DataBoundSetter + public void setShowBuildFlowOnJobPage(boolean showBuildFlowOnJobPage) { + this.showBuildFlowOnJobPage = showBuildFlowOnJobPage; + save(); + } + public static PipelineGraphViewConfiguration get() { return ExtensionList.lookupSingleton(PipelineGraphViewConfiguration.class); } diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction.java new file mode 100644 index 000000000..75487f261 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction.java @@ -0,0 +1,63 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.Action; +import hudson.model.Item; +import hudson.model.Run; +import jakarta.servlet.ServletException; +import java.io.IOException; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; +import org.kohsuke.stapler.WebMethod; +import org.kohsuke.stapler.verb.GET; + +/** + * Invisible action on a {@link Run} that provides the Build Flow API endpoint. + * The Build Flow UI is integrated into the Stages tab via the view dropdown. + */ +public class BuildFlowAction implements Action { + + public static final String URL_NAME = "build-flow"; + + private final Run run; + + public BuildFlowAction(@NonNull Run run) { + this.run = run; + } + + @Override + public String getDisplayName() { + return null; + } + + @Override + public String getUrlName() { + return URL_NAME; + } + + @Override + public String getIconFileName() { + return null; + } + + public Run getRun() { + return run; + } + + public boolean shouldDisplayBuildFlow() { + return BuildFlowGraph.hasUpstreamOrDownstream(run); + } + + @GET + @WebMethod(name = "api") + public void getApi(StaplerRequest2 request, StaplerResponse2 response) throws IOException, ServletException { + run.getParent().checkPermission(Item.READ); + + boolean showUpstream = !"false".equals(request.getParameter("showUpstream")); + boolean showDownstream = !"false".equals(request.getParameter("showDownstream")); + + BuildFlowGraph graph = new BuildFlowGraph(run, showUpstream, showDownstream); + BuildFlowResponse buildFlowResponse = graph.build(); + buildFlowResponse.writeTo(response); + } +} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowActionFactory.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowActionFactory.java new file mode 100644 index 000000000..a77d4212e --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowActionFactory.java @@ -0,0 +1,28 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.Action; +import hudson.model.Run; +import java.util.Collection; +import java.util.Collections; +import jenkins.model.TransientActionFactory; + +/** + * Registers {@link BuildFlowAction} on every {@link Run} to provide the build-flow API endpoint. + * The Build Flow UI is integrated into the Stages tab; no separate tab capsule is shown. + */ +@Extension +public class BuildFlowActionFactory extends TransientActionFactory { + + @Override + public Class type() { + return Run.class; + } + + @NonNull + @Override + public Collection createFor(@NonNull Run target) { + return Collections.singleton(new BuildFlowAction(target)); + } +} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowEdge.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowEdge.java new file mode 100644 index 000000000..58a147729 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowEdge.java @@ -0,0 +1,8 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * DTO representing an edge (trigger relationship) between two builds in the flow graph. + */ +public record BuildFlowEdge(@NonNull String from, @NonNull String to) {} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraph.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraph.java new file mode 100644 index 000000000..a5d223d10 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraph.java @@ -0,0 +1,294 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.model.Cause; +import hudson.model.CauseAction; +import hudson.model.Job; +import hudson.model.Queue; +import hudson.model.Result; +import hudson.model.Run; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import jenkins.model.Jenkins; +import jenkins.model.lazy.LazyBuildMixIn; +import org.jenkinsci.plugins.workflow.support.steps.build.DownstreamBuildAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Builds the full upstream/downstream build flow graph for a given run. + * + *

Upstream traversal uses {@link CauseAction} / {@link Cause.UpstreamCause}. + * Downstream traversal uses {@link DownstreamBuildAction} (from pipeline-build-step-plugin). + */ +public class BuildFlowGraph { + + private static final Logger logger = LoggerFactory.getLogger(BuildFlowGraph.class); + + /** Maximum number of nodes in the graph to prevent runaway traversal. */ + static final int MAX_NODES = 200; + + /** Maximum depth to traverse in either direction. */ + static final int MAX_DEPTH = 50; + + private final Run target; + private final boolean showUpstream; + private final boolean showDownstream; + + public BuildFlowGraph(@NonNull Run target, boolean showUpstream, boolean showDownstream) { + this.target = target; + this.showUpstream = showUpstream; + this.showDownstream = showDownstream; + } + + /** + * Builds the full flow graph response. + */ + public BuildFlowResponse build() { + Map nodeMap = new LinkedHashMap<>(); + List edges = new ArrayList<>(); + boolean anyOngoing = false; + + // Find the root of the chain + Run root = showUpstream ? findRootUpstream(target) : target; + if (root == null) { + root = target; + } + + // BFS downstream from root + Deque> queue = new ArrayDeque<>(); + Set visited = new HashSet<>(); + queue.add(root); + + while (!queue.isEmpty() && nodeMap.size() < MAX_NODES) { + Run current = queue.poll(); + String nodeId = toNodeId(current); + if (visited.contains(nodeId)) { + continue; + } + visited.add(nodeId); + + boolean isCurrent = current.equals(target); + BuildFlowNode node = toNode(current, isCurrent); + nodeMap.put(nodeId, node); + + if (current.isBuilding()) { + anyOngoing = true; + } + + // Find downstream builds. + // When showDownstream=false, still traverse downstream to reach the target, + // but don't go beyond the target node (hide its children). + if (showDownstream || !isCurrent) { + List> downstreamBuilds = getDownstreamBuilds(current); + for (Run downstream : downstreamBuilds) { + String downId = toNodeId(downstream); + edges.add(new BuildFlowEdge(nodeId, downId)); + if (!visited.contains(downId)) { + queue.add(downstream); + } + } + } + } + + // Check for queued downstream items (skip items triggered by target when hiding downstream) + String targetNodeId = toNodeId(target); + { + Queue.Item[] queueItems = Queue.getInstance().getItems(); + for (Queue.Item item : queueItems) { + if (nodeMap.size() >= MAX_NODES) { + break; + } + CauseAction causeAction = item.getAction(CauseAction.class); + if (causeAction == null) { + continue; + } + for (Cause cause : causeAction.getCauses()) { + if (cause instanceof Cause.UpstreamCause upstreamCause) { + String upstreamId = toNodeId(upstreamCause); + // Skip queued items triggered by the target when hiding downstream + if (!showDownstream && upstreamId.equals(targetNodeId)) { + continue; + } + if (nodeMap.containsKey(upstreamId)) { + String queuedId = "queued-" + item.getId(); + if (!nodeMap.containsKey(queuedId)) { + String jobName = item.task.getDisplayName(); + nodeMap.put( + queuedId, + new BuildFlowNode( + queuedId, jobName, jobName, 0, jobName, "", "QUEUED", null, null, null, + false, null)); + edges.add(new BuildFlowEdge(upstreamId, queuedId)); + anyOngoing = true; + } + } + } + } + } + } + + return new BuildFlowResponse(new ArrayList<>(nodeMap.values()), edges, anyOngoing, nodeMap.size() >= MAX_NODES); + } + + /** + * Returns true if this build has any upstream or downstream relationships. + */ + public static boolean hasUpstreamOrDownstream(@Nullable Run run) { + if (run == null) { + return false; + } + // Check upstream + if (getUpstreamBuild(run) != null) { + return true; + } + // Check downstream + return !getDownstreamBuilds(run).isEmpty(); + } + + private static Run findRootUpstream(@NonNull Run build) { + Run current = build; + int depth = 0; + Run parent; + while ((parent = getUpstreamBuild(current)) != null && depth < MAX_DEPTH) { + current = parent; + depth++; + } + return current; + } + + @Nullable + static Run getUpstreamBuild(@NonNull Run build) { + CauseAction causeAction = build.getAction(CauseAction.class); + if (causeAction == null) { + return null; + } + for (Cause cause : causeAction.getCauses()) { + if (cause instanceof Cause.UpstreamCause upstreamCause) { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins == null) { + return null; + } + Job upstreamJob = jenkins.getItemByFullName(upstreamCause.getUpstreamProject(), Job.class); + if (upstreamJob == null) { + continue; + } + // Skip rebuilds (same job as parent) + if (build.getParent().equals(upstreamJob)) { + continue; + } + Run upstreamRun = upstreamJob.getBuildByNumber(upstreamCause.getUpstreamBuild()); + if (upstreamRun != null) { + return upstreamRun; + } + } + } + return null; + } + + @NonNull + static List> getDownstreamBuilds(@NonNull Run run) { + List> result = new ArrayList<>(); + DownstreamBuildAction action = run.getAction(DownstreamBuildAction.class); + if (action != null) { + for (DownstreamBuildAction.DownstreamBuild downstreamBuild : action.getDownstreamBuilds()) { + try { + Run downstream = downstreamBuild.getBuild(); + if (downstream != null) { + result.add(downstream); + } + } catch (Exception e) { + logger.warn("Could not resolve downstream build: {}", e.getMessage()); + } + } + } + return result; + } + + private BuildFlowNode toNode(@NonNull Run run, boolean isCurrent) { + String nodeId = toNodeId(run); + String jobName = run.getParent().getDisplayName(); + String jobFullName = run.getParent().getFullName(); + int buildNumber = run.getNumber(); + String displayName = run.getFullDisplayName(); + String url = run.getUrl(); + String status = mapStatus(run); + Long durationMs = run.isBuilding() ? null : run.getDuration(); + Long startTimeMs = run.isBuilding() ? run.getStartTimeInMillis() : null; + String description = run.getDescription(); + List recentResults = getRecentResults(run); + + return new BuildFlowNode( + nodeId, + jobName, + jobFullName, + buildNumber, + displayName, + url, + status, + durationMs, + startTimeMs, + description, + isCurrent, + recentResults); + } + + private static String toNodeId(@NonNull Run run) { + return run.getParent().getFullName() + "#" + run.getNumber(); + } + + private static String toNodeId(@NonNull Cause.UpstreamCause cause) { + return cause.getUpstreamProject() + "#" + cause.getUpstreamBuild(); + } + + private static String mapStatus(@NonNull Run run) { + if (run.isBuilding()) { + return "IN_PROGRESS"; + } + + Result result = run.getResult(); + if (result == null) { + return "IN_PROGRESS"; + } + + return switch (result.toString()) { + case "SUCCESS", "FAILURE", "UNSTABLE", "ABORTED" -> result.toString(); + default -> "NOT_BUILT"; + }; + } + + private static List getRecentResults(@NonNull Run run) { + List results = new ArrayList<>(6); + // Include the current build's result as the most recent entry + results.add(mapStatus(run)); + + // Avoid loading previous builds into memory if they aren't already there. + // Pattern from JUnit plugin's AbstractTestResultAction. + Set loadedBuilds = null; + if (run.getParent() instanceof LazyBuildMixIn.LazyLoadingJob lazyJob) { + loadedBuilds = + lazyJob.getLazyBuildMixIn()._getRuns().getLoadedBuilds().keySet(); + } + + Run previousBuild = run; + int count = 0; + while (count < 5) { + previousBuild = loadedBuilds == null || loadedBuilds.contains(previousBuild.getNumber() - 1) + ? previousBuild.getPreviousBuild() + : null; + if (previousBuild == null) { + break; + } + results.add(mapStatus(previousBuild)); + count++; + } + return results; + } +} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction.java new file mode 100644 index 000000000..2e91058b5 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction.java @@ -0,0 +1,83 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.Action; +import hudson.model.Item; +import hudson.model.Job; +import hudson.model.Run; +import io.jenkins.plugins.pipelinegraphview.Messages; +import io.jenkins.plugins.pipelinegraphview.PipelineGraphViewConfiguration; +import jakarta.servlet.ServletException; +import java.io.IOException; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; +import org.kohsuke.stapler.WebMethod; +import org.kohsuke.stapler.verb.GET; + +/** + * Action on a {@link Job} that provides the Build Flow card on the job page. + * Shows the upstream/downstream chain of the most recent build as an embedded card, + * similar to how the Stages card appears on the job page. + */ +public class BuildFlowJobAction implements Action { + + public static final String URL_NAME = "build-flow"; + + private final Job job; + + public BuildFlowJobAction(@NonNull Job job) { + this.job = job; + } + + @Override + public String getDisplayName() { + return Messages.buildFlow_title(); + } + + @Override + public String getUrlName() { + return URL_NAME; + } + + @Override + public String getIconFileName() { + return null; + } + + public Job getJob() { + return job; + } + + /** + * Returns the latest build for use in Jelly views. + */ + public Run getLatestBuild() { + return job.getLastBuild(); + } + + public boolean shouldDisplay() { + if (!PipelineGraphViewConfiguration.get().isShowBuildFlowOnJobPage()) { + return false; + } + Run latest = job.getLastBuild(); + return latest != null && BuildFlowGraph.hasUpstreamOrDownstream(latest); + } + + @GET + @WebMethod(name = "api") + public void getApi(StaplerRequest2 request, StaplerResponse2 response) throws IOException, ServletException { + job.checkPermission(Item.READ); + + Run latest = job.getLastBuild(); + if (latest == null) { + new BuildFlowResponse(java.util.List.of(), java.util.List.of(), false, false).writeTo(response); + return; + } + + boolean showUpstream = !"false".equals(request.getParameter("showUpstream")); + boolean showDownstream = !"false".equals(request.getParameter("showDownstream")); + BuildFlowGraph graph = new BuildFlowGraph(latest, showUpstream, showDownstream); + BuildFlowResponse buildFlowResponse = graph.build(); + buildFlowResponse.writeTo(response); + } +} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobActionFactory.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobActionFactory.java new file mode 100644 index 000000000..b5aeeabde --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobActionFactory.java @@ -0,0 +1,27 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.Action; +import hudson.model.Job; +import java.util.Collection; +import java.util.Collections; +import jenkins.model.TransientActionFactory; + +/** + * Registers {@link BuildFlowJobAction} on every {@link Job}. + */ +@Extension +public class BuildFlowJobActionFactory extends TransientActionFactory { + + @Override + public Class type() { + return Job.class; + } + + @NonNull + @Override + public Collection createFor(@NonNull Job target) { + return Collections.singleton(new BuildFlowJobAction(target)); + } +} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowNode.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowNode.java new file mode 100644 index 000000000..c3ce30e3d --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowNode.java @@ -0,0 +1,22 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.util.List; + +/** + * DTO representing a single node (build) in the build flow graph. + */ +public record BuildFlowNode( + @NonNull String id, + @NonNull String jobName, + @NonNull String jobFullName, + int buildNumber, + @NonNull String displayName, + @NonNull String url, + @NonNull String status, + @Nullable Long durationMs, + @Nullable Long startTimeMs, + @Nullable String description, + boolean isCurrentBuild, + @Nullable List recentResults) {} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowResponse.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowResponse.java new file mode 100644 index 000000000..f120e4686 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowResponse.java @@ -0,0 +1,29 @@ +package io.jenkins.plugins.pipelinegraphview.buildflow; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jenkins.plugins.pipelinegraphview.utils.PipelineJsonWriter; +import java.io.IOException; +import java.util.List; +import org.kohsuke.stapler.StaplerResponse2; + +/** + * Response envelope for the build flow API endpoint. + */ +public record BuildFlowResponse( + @NonNull List nodes, + @NonNull List edges, + boolean isAnyBuildOngoing, + boolean isTruncated) { + + /** Writes this response as JSON with appropriate cache headers. */ + void writeTo(@NonNull StaplerResponse2 response) throws IOException { + response.setStatus(200); + response.setContentType("application/json;charset=UTF-8"); + if (!isAnyBuildOngoing) { + response.setHeader("Cache-Control", "private, max-age=60"); + } else { + response.setHeader("Cache-Control", "private, no-store"); + } + PipelineJsonWriter.write(this, response.getOutputStream()); + } +} diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java index 79e52ce4f..945dc57ec 100644 --- a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java +++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java @@ -233,6 +233,15 @@ public boolean isShowGraphOnBuildPage() { return PipelineGraphViewConfiguration.get().isShowGraphOnBuildPage(); } + public boolean isShowBuildFlowOnJobPage() { + return PipelineGraphViewConfiguration.get().isShowBuildFlowOnJobPage(); + } + + public boolean hasBuildFlow() { + return isShowBuildFlowOnJobPage() + && io.jenkins.plugins.pipelinegraphview.buildflow.BuildFlowGraph.hasUpstreamOrDownstream(run); + } + public boolean isBuildable() { return run.getParent().isBuildable(); } diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties b/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties index ca7ba8c0b..97bb9f690 100644 --- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties +++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties @@ -28,6 +28,12 @@ FlowNodeWrapper.noStage=System Generated run.alreadyCancelled=Run was already cancelled run.isFinished=Run is already finished +buildFlow.title=Build Flow +buildFlow.loading=Loading build flow\u2026 +buildFlow.error=Failed to load build flow: {0} +buildFlow.empty=No upstream or downstream builds found. +buildFlow.truncated=Graph truncated at {0} nodes. Some builds may not be shown. + scheduled.success=Build scheduled scheduled.failure=Could not schedule a build @@ -35,6 +41,7 @@ scheduled.failure=Could not schedule a build settings=Settings settings.showStageName=Show stage names settings.showStageDuration=Show stage duration +settings.showBuildFlow=Show Build Flow console.newTab=View step as plain text diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly index 8f75e9bab..ea4892720 100644 --- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly +++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly @@ -18,4 +18,9 @@ + + + + + diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties index 6aede16d4..049a5f1fb 100644 --- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties +++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties @@ -4,3 +4,5 @@ pipelineStageShowStageNames=Show stage names by default pipelineStageShowStageDurations=Show stage durations by default pipelineGraph=Pipeline Graph pipelineGraphShowOnPage=Show pipeline graph on build page +buildFlow=Build Flow +buildFlowShowOnPage=Show build flow on job page diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/index.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/index.jelly new file mode 100644 index 000000000..7c87b800a --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/index.jelly @@ -0,0 +1,35 @@ + + + + + + + + + + + + +

+